Compare commits
49 Commits
multi-tena
...
trustchain
Author | SHA1 | Date |
---|---|---|
Jens Langhammer | 1cd000dfe2 | |
gcp-cherry-pick-bot[bot] | 00ae97944a | |
gcp-cherry-pick-bot[bot] | 9f3ccfb7c7 | |
gcp-cherry-pick-bot[bot] | 9ed9c39ac8 | |
gcp-cherry-pick-bot[bot] | 30b6eeee9f | |
gcp-cherry-pick-bot[bot] | afe2621783 | |
gcp-cherry-pick-bot[bot] | 8b12c6a01a | |
Jens Langhammer | f63adfed96 | |
gcp-cherry-pick-bot[bot] | 9c8fec21cf | |
Jens L | 4776d2bcc5 | |
Jens Langhammer | a15a040362 | |
Jens L | fcd6dc1d60 | |
gcp-cherry-pick-bot[bot] | acc3b59869 | |
gcp-cherry-pick-bot[bot] | d9d5ac10e6 | |
gcp-cherry-pick-bot[bot] | 750669dcab | |
gcp-cherry-pick-bot[bot] | 88a3eed67e | |
gcp-cherry-pick-bot[bot] | 6c214fffc4 | |
gcp-cherry-pick-bot[bot] | 70100fc105 | |
gcp-cherry-pick-bot[bot] | 3c1163fabd | |
gcp-cherry-pick-bot[bot] | 539e8242ff | |
gcp-cherry-pick-bot[bot] | 2648333590 | |
gcp-cherry-pick-bot[bot] | fe828ef993 | |
Jens L | 29a6530742 | |
Jens L | a6b9274c4f | |
Jens Langhammer | a2a67161ac | |
Jens Langhammer | 2e8263a99b | |
gcp-cherry-pick-bot[bot] | 6b9afed21f | |
Jens L | 1eb1f4e0b8 | |
gcp-cherry-pick-bot[bot] | 7c3d60ec3a | |
Jens L | a494c6b6e8 | |
gcp-cherry-pick-bot[bot] | 6604d3577f | |
gcp-cherry-pick-bot[bot] | f8bfa7e16a | |
gcp-cherry-pick-bot[bot] | ea6cf6eabf | |
gcp-cherry-pick-bot[bot] | 769ce3ce7b | |
gcp-cherry-pick-bot[bot] | 3891fb3fa8 | |
gcp-cherry-pick-bot[bot] | 41eb965350 | |
gcp-cherry-pick-bot[bot] | 8d95612287 | |
Jens Langhammer | 82b5274b15 | |
gcp-cherry-pick-bot[bot] | af56ce3d78 | |
gcp-cherry-pick-bot[bot] | f5c6e7aeb0 | |
gcp-cherry-pick-bot[bot] | 3809400e93 | |
gcp-cherry-pick-bot[bot] | 1def9865cf | |
gcp-cherry-pick-bot[bot] | 3716298639 | |
gcp-cherry-pick-bot[bot] | c16317d7cf | |
gcp-cherry-pick-bot[bot] | bbb8fa8269 | |
gcp-cherry-pick-bot[bot] | e4c251a178 | |
gcp-cherry-pick-bot[bot] | 0fefd5f522 | |
gcp-cherry-pick-bot[bot] | 88057db0b0 | |
gcp-cherry-pick-bot[bot] | 91cb6c9beb |
|
@ -9,4 +9,3 @@ blueprints/local
|
||||||
.git
|
.git
|
||||||
!gen-ts-api/node_modules
|
!gen-ts-api/node_modules
|
||||||
!gen-ts-api/dist/**
|
!gen-ts-api/dist/**
|
||||||
!gen-go-api/
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ description: "Setup authentik testing environment"
|
||||||
inputs:
|
inputs:
|
||||||
postgresql_version:
|
postgresql_version:
|
||||||
description: "Optional postgresql image tag"
|
description: "Optional postgresql image tag"
|
||||||
default: "16"
|
default: "12"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
|
@ -18,7 +18,7 @@ runs:
|
||||||
- name: Setup python and restore poetry
|
- name: Setup python and restore poetry
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version-file: "pyproject.toml"
|
python-version-file: 'pyproject.toml'
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
|
|
|
@ -2,7 +2,7 @@ version: "3.7"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgresql:
|
postgresql:
|
||||||
image: docker.io/library/postgres:${PSQL_TAG:-16}
|
image: docker.io/library/postgres:${PSQL_TAG:-12}
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
|
|
|
@ -2,4 +2,3 @@ keypair
|
||||||
keypairs
|
keypairs
|
||||||
hass
|
hass
|
||||||
warmup
|
warmup
|
||||||
ontext
|
|
||||||
|
|
|
@ -35,7 +35,6 @@ updates:
|
||||||
sentry:
|
sentry:
|
||||||
patterns:
|
patterns:
|
||||||
- "@sentry/*"
|
- "@sentry/*"
|
||||||
- "@spotlightjs/*"
|
|
||||||
babel:
|
babel:
|
||||||
patterns:
|
patterns:
|
||||||
- "@babel/*"
|
- "@babel/*"
|
||||||
|
@ -67,7 +66,6 @@ updates:
|
||||||
sentry:
|
sentry:
|
||||||
patterns:
|
patterns:
|
||||||
- "@sentry/*"
|
- "@sentry/*"
|
||||||
- "@spotlightjs/*"
|
|
||||||
babel:
|
babel:
|
||||||
patterns:
|
patterns:
|
||||||
- "@babel/*"
|
- "@babel/*"
|
||||||
|
|
|
@ -61,6 +61,10 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- name: Setup authentik env
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
postgresql_version: ${{ matrix.psql }}
|
||||||
- name: checkout stable
|
- name: checkout stable
|
||||||
run: |
|
run: |
|
||||||
# Delete all poetry envs
|
# Delete all poetry envs
|
||||||
|
@ -72,7 +76,7 @@ jobs:
|
||||||
git checkout version/$(python -c "from authentik import __version__; print(__version__)")
|
git checkout version/$(python -c "from authentik import __version__; print(__version__)")
|
||||||
rm -rf .github/ scripts/
|
rm -rf .github/ scripts/
|
||||||
mv ../.github ../scripts .
|
mv ../.github ../scripts .
|
||||||
- name: Setup authentik env (stable)
|
- name: Setup authentik env (ensure stable deps are installed)
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
postgresql_version: ${{ matrix.psql }}
|
postgresql_version: ${{ matrix.psql }}
|
||||||
|
@ -86,20 +90,14 @@ jobs:
|
||||||
git clean -d -fx .
|
git clean -d -fx .
|
||||||
git checkout $GITHUB_SHA
|
git checkout $GITHUB_SHA
|
||||||
# Delete previous poetry env
|
# Delete previous poetry env
|
||||||
rm -rf /home/runner/.cache/pypoetry/virtualenvs/*
|
rm -rf $(poetry env info --path)
|
||||||
|
poetry install
|
||||||
- name: Setup authentik env (ensure latest deps are installed)
|
- name: Setup authentik env (ensure latest deps are installed)
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
postgresql_version: ${{ matrix.psql }}
|
postgresql_version: ${{ matrix.psql }}
|
||||||
- name: migrate to latest
|
- name: migrate to latest
|
||||||
run: |
|
run: poetry run python -m lifecycle.migrate
|
||||||
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
|
|
||||||
test-unittest:
|
test-unittest:
|
||||||
name: test-unittest - PostgreSQL ${{ matrix.psql }}
|
name: test-unittest - PostgreSQL ${{ matrix.psql }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -249,6 +247,12 @@ jobs:
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
- 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:
|
build-arm64:
|
||||||
needs: ci-core-mark
|
needs: ci-core-mark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -297,26 +301,3 @@ jobs:
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
pr-comment:
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
- build-arm64
|
|
||||||
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
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
- name: prepare variables
|
|
||||||
uses: ./.github/actions/docker-push-variables
|
|
||||||
id: ev
|
|
||||||
env:
|
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
- name: Comment on PR
|
|
||||||
uses: ./.github/actions/comment-pr-instructions
|
|
||||||
with:
|
|
||||||
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Prepare and generate API
|
- name: Prepare and generate API
|
||||||
|
@ -37,7 +37,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
|
@ -65,7 +65,6 @@ jobs:
|
||||||
- proxy
|
- proxy
|
||||||
- ldap
|
- ldap
|
||||||
- radius
|
- radius
|
||||||
- rac
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
# Needed to upload contianer images to ghcr.io
|
# Needed to upload contianer images to ghcr.io
|
||||||
|
@ -120,14 +119,13 @@ jobs:
|
||||||
- proxy
|
- proxy
|
||||||
- ldap
|
- ldap
|
||||||
- radius
|
- radius
|
||||||
- rac
|
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
|
@ -27,10 +27,10 @@ jobs:
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v3
|
uses: github/codeql-action/autobuild@v2
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|
|
@ -6,10 +6,6 @@ on:
|
||||||
types:
|
types:
|
||||||
- closed
|
- closed
|
||||||
|
|
||||||
permissions:
|
|
||||||
# Permission to delete cache
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cleanup:
|
cleanup:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
@ -65,10 +65,9 @@ jobs:
|
||||||
- proxy
|
- proxy
|
||||||
- ldap
|
- ldap
|
||||||
- radius
|
- radius
|
||||||
- rac
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
|
@ -127,7 +126,7 @@ jobs:
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
|
@ -30,7 +30,7 @@ jobs:
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- name: Extract version number
|
- name: Extract version number
|
||||||
id: get_version
|
id: get_version
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.generate_token.outputs.token }}
|
github-token: ${{ steps.generate_token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
|
|
|
@ -18,7 +18,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v8
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||||
days-before-stale: 60
|
days-before-stale: 60
|
||||||
|
|
|
@ -7,12 +7,7 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- "!**"
|
- "!**"
|
||||||
- "locale/**"
|
- "locale/**"
|
||||||
- "!locale/en/**"
|
- "web/src/locales/**"
|
||||||
- "web/xliff/**"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
# Permission to write comment
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
post-comment:
|
post-comment:
|
||||||
|
|
|
@ -6,10 +6,6 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, reopened]
|
types: [opened, reopened]
|
||||||
|
|
||||||
permissions:
|
|
||||||
# Permission to rename PR
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rename_pr:
|
rename_pr:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
"ms-python.pylint",
|
"ms-python.pylint",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
"ms-python.black-formatter",
|
|
||||||
"redhat.vscode-yaml",
|
"redhat.vscode-yaml",
|
||||||
"Tobermory.es6-string-html",
|
"Tobermory.es6-string-html",
|
||||||
"unifiedjs.vscode-mdx",
|
"unifiedjs.vscode-mdx",
|
||||||
|
|
|
@ -19,8 +19,10 @@
|
||||||
"slo",
|
"slo",
|
||||||
"scim",
|
"scim",
|
||||||
],
|
],
|
||||||
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
"todo-tree.tree.showBadges": true,
|
"todo-tree.tree.showBadges": true,
|
||||||
|
"python.formatting.provider": "black",
|
||||||
"yaml.customTags": [
|
"yaml.customTags": [
|
||||||
"!Find sequence",
|
"!Find sequence",
|
||||||
"!KeyOf scalar",
|
"!KeyOf scalar",
|
||||||
|
|
12
Dockerfile
12
Dockerfile
|
@ -37,7 +37,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Build go proxy
|
# Stage 3: Build go proxy
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.6-bookworm AS go-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.4-bookworm AS go-builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
@ -69,9 +69,9 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
|
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
|
||||||
|
|
||||||
# Stage 4: MaxMind GeoIP
|
# Stage 4: MaxMind GeoIP
|
||||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.1 as geoip
|
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.0 as geoip
|
||||||
|
|
||||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
|
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
||||||
ENV GEOIPUPDATE_VERBOSE="true"
|
ENV GEOIPUPDATE_VERBOSE="true"
|
||||||
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
|
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
|
||||||
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
|
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
|
||||||
|
@ -83,7 +83,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||||
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||||
|
|
||||||
# Stage 5: Python dependencies
|
# Stage 5: Python dependencies
|
||||||
FROM docker.io/python:3.12.1-slim-bookworm AS python-deps
|
FROM docker.io/python:3.11.5-bookworm AS python-deps
|
||||||
|
|
||||||
WORKDIR /ak-root/poetry
|
WORKDIR /ak-root/poetry
|
||||||
|
|
||||||
|
@ -108,7 +108,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
|
||||||
poetry install --only=main --no-ansi --no-interaction
|
poetry install --only=main --no-ansi --no-interaction
|
||||||
|
|
||||||
# Stage 6: Run
|
# Stage 6: Run
|
||||||
FROM docker.io/python:3.12.1-slim-bookworm AS final-image
|
FROM docker.io/python:3.11.5-slim-bookworm AS final-image
|
||||||
|
|
||||||
ARG GIT_BUILD_HASH
|
ARG GIT_BUILD_HASH
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
|
@ -125,7 +125,7 @@ WORKDIR /
|
||||||
# We cannot cache this layer otherwise we'll end up with a bigger image
|
# We cannot cache this layer otherwise we'll end up with a bigger image
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
# Required for runtime
|
# 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
|
# Required for bootstrap & healtcheck
|
||||||
apt-get install -y --no-install-recommends runit && \
|
apt-get install -y --no-install-recommends runit && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
|
|
31
Makefile
31
Makefile
|
@ -58,7 +58,7 @@ test: ## Run the server tests and produce a coverage report (locally)
|
||||||
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
||||||
isort $(PY_SOURCES)
|
isort $(PY_SOURCES)
|
||||||
black $(PY_SOURCES)
|
black $(PY_SOURCES)
|
||||||
ruff --fix $(PY_SOURCES)
|
ruff $(PY_SOURCES)
|
||||||
codespell -w $(CODESPELL_ARGS)
|
codespell -w $(CODESPELL_ARGS)
|
||||||
|
|
||||||
lint: ## Lint the python and golang sources
|
lint: ## Lint the python and golang sources
|
||||||
|
@ -94,14 +94,8 @@ dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik
|
||||||
#########################
|
#########################
|
||||||
|
|
||||||
gen-build: ## Extract the schema from the database
|
gen-build: ## Extract the schema from the database
|
||||||
AUTHENTIK_DEBUG=true \
|
AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json
|
||||||
AUTHENTIK_TENANTS__ENABLED=true \
|
AUTHENTIK_DEBUG=true ak spectacular --file schema.yml
|
||||||
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-changelog: ## (Release) generate the changelog based from the commits since the last tag
|
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
|
||||||
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
|
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
|
||||||
|
@ -116,20 +110,13 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a
|
||||||
--markdown /local/diff.md \
|
--markdown /local/diff.md \
|
||||||
/local/old_schema.yml /local/schema.yml
|
/local/old_schema.yml /local/schema.yml
|
||||||
rm old_schema.yml
|
rm old_schema.yml
|
||||||
sed -i 's/{/{/g' diff.md
|
|
||||||
sed -i 's/}/}/g' diff.md
|
|
||||||
npx prettier --write diff.md
|
npx prettier --write diff.md
|
||||||
|
|
||||||
gen-clean-ts: ## Remove generated API client for Typescript
|
gen-clean:
|
||||||
rm -rf gen-ts-api/
|
rm -rf web/api/src/
|
||||||
rm -rf web/node_modules/@goauthentik/api/
|
rm -rf api/
|
||||||
|
|
||||||
gen-clean-go: ## Remove generated API client for Go
|
gen-client-ts: ## Build and install the authentik API for Typescript into the authentik UI Application
|
||||||
rm -rf gen-go-api/
|
|
||||||
|
|
||||||
gen-clean: gen-clean-ts gen-clean-go ## Remove generated API clients
|
|
||||||
|
|
||||||
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
|
|
||||||
docker run \
|
docker run \
|
||||||
--rm -v ${PWD}:/local \
|
--rm -v ${PWD}:/local \
|
||||||
--user ${UID}:${GID} \
|
--user ${UID}:${GID} \
|
||||||
|
@ -145,7 +132,7 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
|
||||||
cd gen-ts-api && npm i
|
cd gen-ts-api && npm i
|
||||||
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
|
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
|
||||||
|
|
||||||
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
gen-client-go: ## Build and install the authentik API for Golang
|
||||||
mkdir -p ./gen-go-api ./gen-go-api/templates
|
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/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/README.mustache -O ./gen-go-api/templates/README.mustache
|
||||||
|
@ -165,7 +152,7 @@ gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
||||||
gen-dev-config: ## Generate a local development config file
|
gen-dev-config: ## Generate a local development config file
|
||||||
python -m scripts.generate_config
|
python -m scripts.generate_config
|
||||||
|
|
||||||
gen: gen-build gen-client-ts
|
gen: gen-build gen-clean gen-client-ts
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
## Web
|
## Web
|
||||||
|
|
|
@ -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.
|
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
|
## 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:
|
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:
|
||||||
|
|
|
@ -13,7 +13,6 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.lib.utils.reflection import get_env
|
from authentik.lib.utils.reflection import get_env
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||||
from authentik.outposts.models import Outpost
|
from authentik.outposts.models import Outpost
|
||||||
|
@ -31,16 +30,15 @@ class RuntimeDict(TypedDict):
|
||||||
uname: str
|
uname: str
|
||||||
|
|
||||||
|
|
||||||
class SystemInfoSerializer(PassiveSerializer):
|
class SystemSerializer(PassiveSerializer):
|
||||||
"""Get system information."""
|
"""Get system information."""
|
||||||
|
|
||||||
http_headers = SerializerMethodField()
|
http_headers = SerializerMethodField()
|
||||||
http_host = SerializerMethodField()
|
http_host = SerializerMethodField()
|
||||||
http_is_secure = SerializerMethodField()
|
http_is_secure = SerializerMethodField()
|
||||||
runtime = SerializerMethodField()
|
runtime = SerializerMethodField()
|
||||||
brand = SerializerMethodField()
|
tenant = SerializerMethodField()
|
||||||
server_time = SerializerMethodField()
|
server_time = SerializerMethodField()
|
||||||
embedded_outpost_disabled = SerializerMethodField()
|
|
||||||
embedded_outpost_host = SerializerMethodField()
|
embedded_outpost_host = SerializerMethodField()
|
||||||
|
|
||||||
def get_http_headers(self, request: Request) -> dict[str, str]:
|
def get_http_headers(self, request: Request) -> dict[str, str]:
|
||||||
|
@ -71,18 +69,14 @@ class SystemInfoSerializer(PassiveSerializer):
|
||||||
"uname": " ".join(platform.uname()),
|
"uname": " ".join(platform.uname()),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_brand(self, request: Request) -> str:
|
def get_tenant(self, request: Request) -> str:
|
||||||
"""Currently active brand"""
|
"""Currently active tenant"""
|
||||||
return str(request._request.brand)
|
return str(request._request.tenant)
|
||||||
|
|
||||||
def get_server_time(self, request: Request) -> datetime:
|
def get_server_time(self, request: Request) -> datetime:
|
||||||
"""Current server time"""
|
"""Current server time"""
|
||||||
return now()
|
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:
|
def get_embedded_outpost_host(self, request: Request) -> str:
|
||||||
"""Get the FQDN configured on the embedded outpost"""
|
"""Get the FQDN configured on the embedded outpost"""
|
||||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||||
|
@ -97,14 +91,14 @@ class SystemView(APIView):
|
||||||
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
|
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
|
||||||
pagination_class = None
|
pagination_class = None
|
||||||
filter_backends = []
|
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:
|
def get(self, request: Request) -> Response:
|
||||||
"""Get system information."""
|
"""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:
|
def post(self, request: Request) -> Response:
|
||||||
"""Get system information."""
|
"""Get system information."""
|
||||||
return Response(SystemInfoSerializer(request).data)
|
return Response(SystemSerializer(request).data)
|
||||||
|
|
|
@ -15,6 +15,6 @@ class AuthentikAdminConfig(ManagedAppConfig):
|
||||||
verbose_name = "authentik Admin"
|
verbose_name = "authentik Admin"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
def reconcile_global_load_admin_signals(self):
|
def reconcile_load_admin_signals(self):
|
||||||
"""Load admin signals"""
|
"""Load admin signals"""
|
||||||
self.import_module("authentik.admin.signals")
|
self.import_module("authentik.admin.signals")
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
API Browser - {{ brand.branding_title }}
|
API Browser - {{ tenant.branding_title }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
|
|
@ -12,8 +12,6 @@ from authentik.blueprints.tests import reconcile_app
|
||||||
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.lib.generators import generate_id
|
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.constants import SCOPE_AUTHENTIK_API
|
||||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||||
|
|
||||||
|
@ -51,12 +49,8 @@ class TestAPIAuth(TestCase):
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
bearer_auth(f"Bearer {token.key}".encode())
|
bearer_auth(f"Bearer {token.key}".encode())
|
||||||
|
|
||||||
@reconcile_app("authentik_outposts")
|
def test_managed_outpost(self):
|
||||||
def test_managed_outpost_fail(self):
|
|
||||||
"""Test managed outpost"""
|
"""Test managed outpost"""
|
||||||
outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
|
|
||||||
outpost.user.delete()
|
|
||||||
outpost.delete()
|
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.events.context_processors.base import get_context_processors
|
from authentik.events.geo import GEOIP_READER
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
|
|
||||||
capabilities = Signal()
|
capabilities = Signal()
|
||||||
|
@ -30,7 +30,6 @@ class Capabilities(models.TextChoices):
|
||||||
|
|
||||||
CAN_SAVE_MEDIA = "can_save_media"
|
CAN_SAVE_MEDIA = "can_save_media"
|
||||||
CAN_GEO_IP = "can_geo_ip"
|
CAN_GEO_IP = "can_geo_ip"
|
||||||
CAN_ASN = "can_asn"
|
|
||||||
CAN_IMPERSONATE = "can_impersonate"
|
CAN_IMPERSONATE = "can_impersonate"
|
||||||
CAN_DEBUG = "can_debug"
|
CAN_DEBUG = "can_debug"
|
||||||
IS_ENTERPRISE = "is_enterprise"
|
IS_ENTERPRISE = "is_enterprise"
|
||||||
|
@ -69,10 +68,9 @@ class ConfigView(APIView):
|
||||||
deb_test = settings.DEBUG or settings.TEST
|
deb_test = settings.DEBUG or settings.TEST
|
||||||
if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
|
if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
|
||||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||||
for processor in get_context_processors():
|
if GEOIP_READER.enabled:
|
||||||
if cap := processor.capability():
|
caps.append(Capabilities.CAN_GEO_IP)
|
||||||
caps.append(cap)
|
if CONFIG.get_bool("impersonation"):
|
||||||
if self.request.tenant.impersonation:
|
|
||||||
caps.append(Capabilities.CAN_IMPERSONATE)
|
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||||
if settings.DEBUG: # pragma: no cover
|
if settings.DEBUG: # pragma: no cover
|
||||||
caps.append(Capabilities.CAN_DEBUG)
|
caps.append(Capabilities.CAN_DEBUG)
|
||||||
|
@ -95,10 +93,10 @@ class ConfigView(APIView):
|
||||||
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
|
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
|
||||||
},
|
},
|
||||||
"capabilities": self.get_capabilities(),
|
"capabilities": self.get_capabilities(),
|
||||||
"cache_timeout": CONFIG.get_int("cache.timeout"),
|
"cache_timeout": CONFIG.get_int("redis.cache_timeout"),
|
||||||
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
|
"cache_timeout_flows": CONFIG.get_int("redis.cache_timeout_flows"),
|
||||||
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
|
"cache_timeout_policies": CONFIG.get_int("redis.cache_timeout_policies"),
|
||||||
"cache_timeout_reputation": CONFIG.get_int("cache.timeout_reputation"),
|
"cache_timeout_reputation": CONFIG.get_int("redis.cache_timeout_reputation"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import CharField, DateTimeField
|
from rest_framework.fields import CharField, DateTimeField, JSONField
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ListSerializer, ModelSerializer
|
from rest_framework.serializers import ListSerializer, ModelSerializer
|
||||||
|
@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer
|
||||||
from authentik.blueprints.v1.oci import OCI_PREFIX
|
from authentik.blueprints.v1.oci import OCI_PREFIX
|
||||||
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import JSONDictField, PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
|
||||||
|
|
||||||
class ManagedSerializer:
|
class ManagedSerializer:
|
||||||
|
@ -28,7 +28,7 @@ class MetadataSerializer(PassiveSerializer):
|
||||||
"""Serializer for blueprint metadata"""
|
"""Serializer for blueprint metadata"""
|
||||||
|
|
||||||
name = CharField()
|
name = CharField()
|
||||||
labels = JSONDictField()
|
labels = JSONField()
|
||||||
|
|
||||||
|
|
||||||
class BlueprintInstanceSerializer(ModelSerializer):
|
class BlueprintInstanceSerializer(ModelSerializer):
|
||||||
|
|
|
@ -13,23 +13,21 @@ class ManagedAppConfig(AppConfig):
|
||||||
|
|
||||||
_logger: BoundLogger
|
_logger: BoundLogger
|
||||||
|
|
||||||
RECONCILE_GLOBAL_PREFIX: str = "reconcile_global_"
|
|
||||||
RECONCILE_TENANT_PREFIX: str = "reconcile_tenant_"
|
|
||||||
|
|
||||||
def __init__(self, app_name: str, *args, **kwargs) -> None:
|
def __init__(self, app_name: str, *args, **kwargs) -> None:
|
||||||
super().__init__(app_name, *args, **kwargs)
|
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:
|
def ready(self) -> None:
|
||||||
self.reconcile_global()
|
self.reconcile()
|
||||||
self.reconcile_tenant()
|
|
||||||
return super().ready()
|
return super().ready()
|
||||||
|
|
||||||
def import_module(self, path: str):
|
def import_module(self, path: str):
|
||||||
"""Load module"""
|
"""Load module"""
|
||||||
import_module(path)
|
import_module(path)
|
||||||
|
|
||||||
def _reconcile(self, prefix: str) -> None:
|
def reconcile(self) -> None:
|
||||||
|
"""reconcile ourselves"""
|
||||||
|
prefix = "reconcile_"
|
||||||
for meth_name in dir(self):
|
for meth_name in dir(self):
|
||||||
meth = getattr(self, meth_name)
|
meth = getattr(self, meth_name)
|
||||||
if not ismethod(meth):
|
if not ismethod(meth):
|
||||||
|
@ -44,29 +42,6 @@ class ManagedAppConfig(AppConfig):
|
||||||
except (DatabaseError, ProgrammingError, InternalError) as exc:
|
except (DatabaseError, ProgrammingError, InternalError) as exc:
|
||||||
self._logger.warning("Failed to run reconcile", name=name, exc=exc)
|
self._logger.warning("Failed to run reconcile", name=name, exc=exc)
|
||||||
|
|
||||||
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_PREFIX)
|
|
||||||
|
|
||||||
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_PREFIX)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikBlueprintsConfig(ManagedAppConfig):
|
class AuthentikBlueprintsConfig(ManagedAppConfig):
|
||||||
"""authentik Blueprints app"""
|
"""authentik Blueprints app"""
|
||||||
|
@ -76,11 +51,11 @@ class AuthentikBlueprintsConfig(ManagedAppConfig):
|
||||||
verbose_name = "authentik Blueprints"
|
verbose_name = "authentik Blueprints"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
def reconcile_global_load_blueprints_v1_tasks(self):
|
def reconcile_load_blueprints_v1_tasks(self):
|
||||||
"""Load v1 tasks"""
|
"""Load v1 tasks"""
|
||||||
self.import_module("authentik.blueprints.v1.tasks")
|
self.import_module("authentik.blueprints.v1.tasks")
|
||||||
|
|
||||||
def reconcile_tenant_blueprints_discovery(self):
|
def reconcile_blueprints_discovery(self):
|
||||||
"""Run blueprint discovery"""
|
"""Run blueprint discovery"""
|
||||||
from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints
|
from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.blueprints.models import BlueprintInstance
|
from authentik.blueprints.models import BlueprintInstance
|
||||||
from authentik.blueprints.v1.importer import Importer
|
from authentik.blueprints.v1.importer import Importer
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
@ -17,8 +16,6 @@ class Command(BaseCommand):
|
||||||
@no_translations
|
@no_translations
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Apply all blueprints in order, abort when one fails to import"""
|
"""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", []):
|
for blueprint_path in options.get("blueprints", []):
|
||||||
content = BlueprintInstance(path=blueprint_path).retrieve()
|
content = BlueprintInstance(path=blueprint_path).retrieve()
|
||||||
importer = Importer.from_string(content)
|
importer = Importer.from_string(content)
|
||||||
|
|
|
@ -1,18 +1,17 @@
|
||||||
"""Export blueprint of current authentik install"""
|
"""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 structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.blueprints.v1.exporter import Exporter
|
from authentik.blueprints.v1.exporter import Exporter
|
||||||
from authentik.tenants.management import TenantCommand
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class Command(TenantCommand):
|
class Command(BaseCommand):
|
||||||
"""Export blueprint of current authentik install"""
|
"""Export blueprint of current authentik install"""
|
||||||
|
|
||||||
@no_translations
|
@no_translations
|
||||||
def handle_per_tenant(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Export blueprint of current authentik install"""
|
"""Export blueprint of current authentik install"""
|
||||||
exporter = Exporter()
|
exporter = Exporter()
|
||||||
self.stdout.write(exporter.export_to_string())
|
self.stdout.write(exporter.export_to_string())
|
||||||
|
|
|
@ -14,7 +14,7 @@ from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM
|
||||||
from authentik.lib.config import CONFIG
|
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"""
|
"""Check if blueprint should be imported"""
|
||||||
from authentik.blueprints.models import BlueprintInstanceStatus
|
from authentik.blueprints.models import BlueprintInstanceStatus
|
||||||
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
|
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:
|
if version != 1:
|
||||||
return
|
return
|
||||||
blueprint_file.seek(0)
|
blueprint_file.seek(0)
|
||||||
instance: BlueprintInstance = (
|
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
|
||||||
BlueprintInstance.objects.using(db_alias).filter(path=path).first()
|
|
||||||
)
|
|
||||||
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
|
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
|
||||||
meta = None
|
meta = None
|
||||||
if metadata:
|
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":
|
if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false":
|
||||||
return
|
return
|
||||||
if not instance:
|
if not instance:
|
||||||
BlueprintInstance.objects.using(db_alias).create(
|
instance = BlueprintInstance(
|
||||||
name=meta.name if meta else str(rel_path),
|
name=meta.name if meta else str(rel_path),
|
||||||
path=str(rel_path),
|
path=str(rel_path),
|
||||||
context={},
|
context={},
|
||||||
|
@ -49,6 +47,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
|
||||||
last_applied_hash="",
|
last_applied_hash="",
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
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
|
db_alias = schema_editor.connection.alias
|
||||||
for file in glob(f"{CONFIG.get('blueprints_dir')}/**/*.yaml", recursive=True):
|
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():
|
for blueprint in BlueprintInstance.objects.using(db_alias).all():
|
||||||
# If we already have flows (and we should always run before flow migrations)
|
# If we already have flows (and we should always run before flow migrations)
|
||||||
|
|
|
@ -38,7 +38,7 @@ def reconcile_app(app_name: str):
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
config = apps.get_app_config(app_name)
|
config = apps.get_app_config(app_name)
|
||||||
if isinstance(config, ManagedAppConfig):
|
if isinstance(config, ManagedAppConfig):
|
||||||
config.ready()
|
config.reconcile()
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
|
@ -7,16 +7,16 @@ from django.test import TransactionTestCase
|
||||||
from authentik.blueprints.models import BlueprintInstance
|
from authentik.blueprints.models import BlueprintInstance
|
||||||
from authentik.blueprints.tests import apply_blueprint
|
from authentik.blueprints.tests import apply_blueprint
|
||||||
from authentik.blueprints.v1.importer import Importer
|
from authentik.blueprints.v1.importer import Importer
|
||||||
from authentik.brands.models import Brand
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
class TestPackaged(TransactionTestCase):
|
class TestPackaged(TransactionTestCase):
|
||||||
"""Empty class, test methods are added dynamically"""
|
"""Empty class, test methods are added dynamically"""
|
||||||
|
|
||||||
@apply_blueprint("default/default-brand.yaml")
|
@apply_blueprint("default/default-tenant.yaml")
|
||||||
def test_decorator_static(self):
|
def test_decorator_static(self):
|
||||||
"""Test @apply_blueprint decorator"""
|
"""Test @apply_blueprint decorator"""
|
||||||
self.assertTrue(Brand.objects.filter(domain="authentik-default").exists())
|
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
|
||||||
|
|
||||||
|
|
||||||
def blueprint_tester(file_name: Path) -> Callable:
|
def blueprint_tester(file_name: Path) -> Callable:
|
||||||
|
|
|
@ -35,7 +35,7 @@ from authentik.core.models import (
|
||||||
Source,
|
Source,
|
||||||
UserSourceConnection,
|
UserSourceConnection,
|
||||||
)
|
)
|
||||||
from authentik.enterprise.models import LicenseKey, LicenseUsage
|
from authentik.enterprise.models import LicenseUsage
|
||||||
from authentik.events.utils import cleanse_dict
|
from authentik.events.utils import cleanse_dict
|
||||||
from authentik.flows.models import FlowToken, Stage
|
from authentik.flows.models import FlowToken, Stage
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
|
@ -43,7 +43,6 @@ from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.outposts.models import OutpostServiceConnection
|
from authentik.outposts.models import OutpostServiceConnection
|
||||||
from authentik.policies.models import Policy, PolicyBindingModel
|
from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
# Context set when the serializer is created in a blueprint context
|
# Context set when the serializer is created in a blueprint context
|
||||||
# Update website/developer-docs/blueprints/v1/models.md when used
|
# Update website/developer-docs/blueprints/v1/models.md when used
|
||||||
|
@ -58,7 +57,6 @@ def excluded_models() -> list[type[Model]]:
|
||||||
from django.contrib.auth.models import User as DjangoUser
|
from django.contrib.auth.models import User as DjangoUser
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Tenant,
|
|
||||||
DjangoUser,
|
DjangoUser,
|
||||||
DjangoGroup,
|
DjangoGroup,
|
||||||
# Base classes
|
# Base classes
|
||||||
|
@ -110,16 +108,12 @@ class Importer:
|
||||||
self.__pk_map: dict[Any, Model] = {}
|
self.__pk_map: dict[Any, Model] = {}
|
||||||
self._import = blueprint
|
self._import = blueprint
|
||||||
self.logger = get_logger()
|
self.logger = get_logger()
|
||||||
ctx = self.default_context()
|
ctx = {}
|
||||||
always_merger.merge(ctx, self._import.context)
|
always_merger.merge(ctx, self._import.context)
|
||||||
if context:
|
if context:
|
||||||
always_merger.merge(ctx, context)
|
always_merger.merge(ctx, context)
|
||||||
self._import.context = ctx
|
self._import.context = ctx
|
||||||
|
|
||||||
def default_context(self):
|
|
||||||
"""Default context"""
|
|
||||||
return {"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid()}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
|
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
|
||||||
"""Parse YAML string and create blueprint importer from it"""
|
"""Parse YAML string and create blueprint importer from it"""
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from rest_framework.exceptions import ValidationError
|
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 structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.blueprints.v1.meta.registry import BaseMetaModel, MetaResult, registry
|
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:
|
if TYPE_CHECKING:
|
||||||
from authentik.blueprints.models import BlueprintInstance
|
from authentik.blueprints.models import BlueprintInstance
|
||||||
|
@ -17,7 +17,7 @@ LOGGER = get_logger()
|
||||||
class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
||||||
"""Serializer for meta apply blueprint model"""
|
"""Serializer for meta apply blueprint model"""
|
||||||
|
|
||||||
identifiers = JSONDictField()
|
identifiers = JSONField(validators=[is_dict])
|
||||||
required = BooleanField(default=True)
|
required = BooleanField(default=True)
|
||||||
|
|
||||||
# We cannot override `instance` as that will confuse rest_framework
|
# We cannot override `instance` as that will confuse rest_framework
|
||||||
|
|
|
@ -38,7 +38,6 @@ from authentik.events.monitored_tasks import (
|
||||||
from authentik.events.utils import sanitize_dict
|
from authentik.events.utils import sanitize_dict
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
_file_watcher_started = False
|
_file_watcher_started = False
|
||||||
|
@ -79,11 +78,6 @@ class BlueprintEventHandler(FileSystemEventHandler):
|
||||||
root = Path(CONFIG.get("blueprints_dir")).absolute()
|
root = Path(CONFIG.get("blueprints_dir")).absolute()
|
||||||
path = Path(event.src_path).absolute()
|
path = Path(event.src_path).absolute()
|
||||||
rel_path = str(path.relative_to(root))
|
rel_path = str(path.relative_to(root))
|
||||||
for tenant in Tenant.objects.filter(ready=True):
|
|
||||||
with tenant:
|
|
||||||
root = Path(CONFIG.get("blueprints_dir")).absolute()
|
|
||||||
path = Path(event.src_path).absolute()
|
|
||||||
rel_path = str(path.relative_to(root))
|
|
||||||
if isinstance(event, FileCreatedEvent):
|
if isinstance(event, FileCreatedEvent):
|
||||||
LOGGER.debug("new blueprint file created, starting discovery", path=rel_path)
|
LOGGER.debug("new blueprint file created, starting discovery", path=rel_path)
|
||||||
blueprints_discovery.delay(rel_path)
|
blueprints_discovery.delay(rel_path)
|
||||||
|
|
|
@ -1,10 +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"
|
|
|
@ -1,26 +0,0 @@
|
||||||
"""Inject brand into current request"""
|
|
||||||
from typing 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)
|
|
||||||
setattr(request, "brand", brand)
|
|
||||||
locale = brand.default_locale
|
|
||||||
if locale != "":
|
|
||||||
activate(locale)
|
|
||||||
return self.get_response(request)
|
|
|
@ -1,21 +0,0 @@
|
||||||
# Generated by Django 4.2.7 on 2023-12-12 06:41
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("authentik_brands", "0004_tenant_flow_device_code"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name="brand",
|
|
||||||
old_name="tenant_uuid",
|
|
||||||
new_name="brand_uuid",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="brand",
|
|
||||||
name="event_retention",
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,85 +0,0 @@
|
||||||
"""brand models"""
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from rest_framework.serializers import Serializer
|
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
|
||||||
from authentik.flows.models import Flow
|
|
||||||
from authentik.lib.models import SerializerModel
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class Brand(SerializerModel):
|
|
||||||
"""Single brand"""
|
|
||||||
|
|
||||||
brand_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
|
||||||
domain = models.TextField(
|
|
||||||
help_text=_(
|
|
||||||
"Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
default = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
branding_title = models.TextField(default="authentik")
|
|
||||||
|
|
||||||
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
|
|
||||||
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
|
|
||||||
|
|
||||||
flow_authentication = models.ForeignKey(
|
|
||||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_authentication"
|
|
||||||
)
|
|
||||||
flow_invalidation = models.ForeignKey(
|
|
||||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_invalidation"
|
|
||||||
)
|
|
||||||
flow_recovery = models.ForeignKey(
|
|
||||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_recovery"
|
|
||||||
)
|
|
||||||
flow_unenrollment = models.ForeignKey(
|
|
||||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_unenrollment"
|
|
||||||
)
|
|
||||||
flow_user_settings = models.ForeignKey(
|
|
||||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_user_settings"
|
|
||||||
)
|
|
||||||
flow_device_code = models.ForeignKey(
|
|
||||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
|
|
||||||
)
|
|
||||||
|
|
||||||
web_certificate = models.ForeignKey(
|
|
||||||
CertificateKeyPair,
|
|
||||||
null=True,
|
|
||||||
default=None,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
help_text=_("Web Certificate used by the authentik Core webserver."),
|
|
||||||
)
|
|
||||||
attributes = models.JSONField(default=dict, blank=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer(self) -> Serializer:
|
|
||||||
from authentik.brands.api import BrandSerializer
|
|
||||||
|
|
||||||
return BrandSerializer
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default_locale(self) -> str:
|
|
||||||
"""Get default locale"""
|
|
||||||
try:
|
|
||||||
return self.attributes.get("settings", {}).get("locale", "")
|
|
||||||
# pylint: disable=broad-except
|
|
||||||
except Exception as exc:
|
|
||||||
LOGGER.warning("Failed to get default locale", exc=exc)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
if self.default:
|
|
||||||
return "Default brand"
|
|
||||||
return f"Brand {self.domain}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Brand")
|
|
||||||
verbose_name_plural = _("Brands")
|
|
|
@ -1,76 +0,0 @@
|
||||||
"""Test brands"""
|
|
||||||
from django.urls import reverse
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from authentik.brands.api import Themes
|
|
||||||
from authentik.brands.models import Brand
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
|
|
||||||
|
|
||||||
|
|
||||||
class TestBrands(APITestCase):
|
|
||||||
"""Test brands"""
|
|
||||||
|
|
||||||
def test_current_brand(self):
|
|
||||||
"""Test Current brand API"""
|
|
||||||
brand = create_test_brand()
|
|
||||||
self.assertJSONEqual(
|
|
||||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
|
||||||
{
|
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
|
||||||
"branding_title": "authentik",
|
|
||||||
"matched_domain": brand.domain,
|
|
||||||
"ui_footer_links": [],
|
|
||||||
"ui_theme": Themes.AUTOMATIC,
|
|
||||||
"default_locale": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_brand_subdomain(self):
|
|
||||||
"""Test Current brand API"""
|
|
||||||
Brand.objects.all().delete()
|
|
||||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
|
||||||
self.assertJSONEqual(
|
|
||||||
self.client.get(
|
|
||||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
|
||||||
).content.decode(),
|
|
||||||
{
|
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
|
||||||
"branding_title": "custom",
|
|
||||||
"matched_domain": "bar.baz",
|
|
||||||
"ui_footer_links": [],
|
|
||||||
"ui_theme": Themes.AUTOMATIC,
|
|
||||||
"default_locale": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_fallback(self):
|
|
||||||
"""Test fallback brand"""
|
|
||||||
Brand.objects.all().delete()
|
|
||||||
self.assertJSONEqual(
|
|
||||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
|
||||||
{
|
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
|
||||||
"branding_title": "authentik",
|
|
||||||
"matched_domain": "fallback",
|
|
||||||
"ui_footer_links": [],
|
|
||||||
"ui_theme": Themes.AUTOMATIC,
|
|
||||||
"default_locale": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_create_default_multiple(self):
|
|
||||||
"""Test attempted creation of multiple default brands"""
|
|
||||||
Brand.objects.create(
|
|
||||||
domain="foo",
|
|
||||||
default=True,
|
|
||||||
branding_title="custom",
|
|
||||||
)
|
|
||||||
user = create_test_admin_user()
|
|
||||||
self.client.force_login(user)
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("authentik_api:brand-list"), data={"domain": "bar", "default": True}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
|
@ -1,6 +0,0 @@
|
||||||
"""API URLs"""
|
|
||||||
from authentik.brands.api import BrandViewSet
|
|
||||||
|
|
||||||
api_urlpatterns = [
|
|
||||||
("core/brands", BrandViewSet),
|
|
||||||
]
|
|
|
@ -1,42 +0,0 @@
|
||||||
"""Brand utilities"""
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.db.models import F, Q
|
|
||||||
from django.db.models import Value as V
|
|
||||||
from django.http.request import HttpRequest
|
|
||||||
from sentry_sdk.hub import Hub
|
|
||||||
|
|
||||||
from authentik import get_full_version
|
|
||||||
from authentik.brands.models import Brand
|
|
||||||
from authentik.tenants.utils import get_current_tenant
|
|
||||||
|
|
||||||
_q_default = Q(default=True)
|
|
||||||
DEFAULT_BRAND = Brand(domain="fallback")
|
|
||||||
|
|
||||||
|
|
||||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
|
||||||
"""Get brand object for current request"""
|
|
||||||
db_brands = (
|
|
||||||
Brand.objects.annotate(host_domain=V(request.get_host()))
|
|
||||||
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
|
||||||
.order_by("default")
|
|
||||||
)
|
|
||||||
brands = list(db_brands.all())
|
|
||||||
if len(brands) < 1:
|
|
||||||
return DEFAULT_BRAND
|
|
||||||
return brands[0]
|
|
||||||
|
|
||||||
|
|
||||||
def context_processor(request: HttpRequest) -> dict[str, Any]:
|
|
||||||
"""Context Processor that injects brand object into every template"""
|
|
||||||
brand = getattr(request, "brand", DEFAULT_BRAND)
|
|
||||||
trace = ""
|
|
||||||
span = Hub.current.scope.span
|
|
||||||
if span:
|
|
||||||
trace = span.to_traceparent()
|
|
||||||
return {
|
|
||||||
"brand": brand,
|
|
||||||
"footer_links": get_current_tenant().footer_links,
|
|
||||||
"sentry_trace": trace,
|
|
||||||
"version": get_full_version(),
|
|
||||||
}
|
|
|
@ -14,8 +14,7 @@ from ua_parser import user_agent_parser
|
||||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
from authentik.api.authorization import OwnerSuperuserPermissions
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
|
from authentik.events.geo import GEOIP_READER, GeoIPDict
|
||||||
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
|
|
||||||
|
|
||||||
|
|
||||||
class UserAgentDeviceDict(TypedDict):
|
class UserAgentDeviceDict(TypedDict):
|
||||||
|
@ -60,7 +59,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
current = SerializerMethodField()
|
current = SerializerMethodField()
|
||||||
user_agent = SerializerMethodField()
|
user_agent = SerializerMethodField()
|
||||||
geo_ip = SerializerMethodField()
|
geo_ip = SerializerMethodField()
|
||||||
asn = SerializerMethodField()
|
|
||||||
|
|
||||||
def get_current(self, instance: AuthenticatedSession) -> bool:
|
def get_current(self, instance: AuthenticatedSession) -> bool:
|
||||||
"""Check if session is currently active session"""
|
"""Check if session is currently active session"""
|
||||||
|
@ -72,12 +70,8 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
return user_agent_parser.Parse(instance.last_user_agent)
|
return user_agent_parser.Parse(instance.last_user_agent)
|
||||||
|
|
||||||
def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover
|
def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover
|
||||||
"""Get GeoIP Data"""
|
"""Get parsed user agent"""
|
||||||
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip)
|
return GEOIP_READER.city_dict(instance.last_ip)
|
||||||
|
|
||||||
def get_asn(self, instance: AuthenticatedSession) -> Optional[ASNDict]: # pragma: no cover
|
|
||||||
"""Get ASN Data"""
|
|
||||||
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AuthenticatedSession
|
model = AuthenticatedSession
|
||||||
|
@ -86,7 +80,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
"current",
|
"current",
|
||||||
"user_agent",
|
"user_agent",
|
||||||
"geo_ip",
|
"geo_ip",
|
||||||
"asn",
|
|
||||||
"user",
|
"user",
|
||||||
"last_ip",
|
"last_ip",
|
||||||
"last_user_agent",
|
"last_user_agent",
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django_filters.filterset import FilterSet
|
||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, IntegerField
|
from rest_framework.fields import CharField, IntegerField, JSONField
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
|
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
|
||||||
|
@ -16,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import JSONDictField, PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer, is_dict
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
from authentik.rbac.api.roles import RoleSerializer
|
from authentik.rbac.api.roles import RoleSerializer
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ from authentik.rbac.api.roles import RoleSerializer
|
||||||
class GroupMemberSerializer(ModelSerializer):
|
class GroupMemberSerializer(ModelSerializer):
|
||||||
"""Stripped down user serializer to show relevant users for groups"""
|
"""Stripped down user serializer to show relevant users for groups"""
|
||||||
|
|
||||||
attributes = JSONDictField(required=False)
|
attributes = JSONField(validators=[is_dict], required=False)
|
||||||
uid = CharField(read_only=True)
|
uid = CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -44,7 +44,7 @@ class GroupMemberSerializer(ModelSerializer):
|
||||||
class GroupSerializer(ModelSerializer):
|
class GroupSerializer(ModelSerializer):
|
||||||
"""Group Serializer"""
|
"""Group Serializer"""
|
||||||
|
|
||||||
attributes = JSONDictField(required=False)
|
attributes = JSONField(validators=[is_dict], required=False)
|
||||||
users_obj = ListSerializer(
|
users_obj = ListSerializer(
|
||||||
child=GroupMemberSerializer(), read_only=True, source="users", required=False
|
child=GroupMemberSerializer(), read_only=True, source="users", required=False
|
||||||
)
|
)
|
||||||
|
|
|
@ -19,7 +19,6 @@ from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer
|
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer
|
||||||
from authentik.core.expression.evaluator import PropertyMappingEvaluator
|
from authentik.core.expression.evaluator import PropertyMappingEvaluator
|
||||||
from authentik.core.models import PropertyMapping
|
from authentik.core.models import PropertyMapping
|
||||||
from authentik.enterprise.apps import EnterpriseConfig
|
|
||||||
from authentik.events.utils import sanitize_item
|
from authentik.events.utils import sanitize_item
|
||||||
from authentik.lib.utils.reflection import all_subclasses
|
from authentik.lib.utils.reflection import all_subclasses
|
||||||
from authentik.policies.api.exec import PolicyTestSerializer
|
from authentik.policies.api.exec import PolicyTestSerializer
|
||||||
|
@ -96,7 +95,6 @@ class PropertyMappingViewSet(
|
||||||
"description": subclass.__doc__,
|
"description": subclass.__doc__,
|
||||||
"component": subclass().component,
|
"component": subclass().component,
|
||||||
"model_name": subclass._meta.model_name,
|
"model_name": subclass._meta.model_name,
|
||||||
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return Response(TypeCreateSerializer(data, many=True).data)
|
return Response(TypeCreateSerializer(data, many=True).data)
|
||||||
|
|
|
@ -16,7 +16,6 @@ from rest_framework.viewsets import GenericViewSet
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
||||||
from authentik.core.models import Provider
|
from authentik.core.models import Provider
|
||||||
from authentik.enterprise.apps import EnterpriseConfig
|
|
||||||
from authentik.lib.utils.reflection import all_subclasses
|
from authentik.lib.utils.reflection import all_subclasses
|
||||||
|
|
||||||
|
|
||||||
|
@ -114,7 +113,6 @@ class ProviderViewSet(
|
||||||
"description": subclass.__doc__,
|
"description": subclass.__doc__,
|
||||||
"component": subclass().component,
|
"component": subclass().component,
|
||||||
"model_name": subclass._meta.model_name,
|
"model_name": subclass._meta.model_name,
|
||||||
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
data.append(
|
data.append(
|
||||||
|
|
|
@ -32,7 +32,13 @@ from drf_spectacular.utils import (
|
||||||
)
|
)
|
||||||
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, IntegerField, ListField, SerializerMethodField
|
from rest_framework.fields import (
|
||||||
|
CharField,
|
||||||
|
IntegerField,
|
||||||
|
JSONField,
|
||||||
|
ListField,
|
||||||
|
SerializerMethodField,
|
||||||
|
)
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import (
|
from rest_framework.serializers import (
|
||||||
|
@ -50,9 +56,8 @@ from structlog.stdlib import get_logger
|
||||||
from authentik.admin.api.metrics import CoordinateSerializer
|
from authentik.admin.api.metrics import CoordinateSerializer
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||||
from authentik.brands.models import Brand
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer
|
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
|
||||||
from authentik.core.middleware import (
|
from authentik.core.middleware import (
|
||||||
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||||
SESSION_KEY_IMPERSONATE_USER,
|
SESSION_KEY_IMPERSONATE_USER,
|
||||||
|
@ -72,9 +77,11 @@ from authentik.flows.exceptions import FlowNonApplicableException
|
||||||
from authentik.flows.models import FlowToken
|
from authentik.flows.models import FlowToken
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
from authentik.stages.email.tasks import send_mails
|
from authentik.stages.email.tasks import send_mails
|
||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
@ -82,7 +89,7 @@ LOGGER = get_logger()
|
||||||
class UserGroupSerializer(ModelSerializer):
|
class UserGroupSerializer(ModelSerializer):
|
||||||
"""Simplified Group Serializer for user's groups"""
|
"""Simplified Group Serializer for user's groups"""
|
||||||
|
|
||||||
attributes = JSONDictField(required=False)
|
attributes = JSONField(required=False)
|
||||||
parent_name = CharField(source="parent.name", read_only=True)
|
parent_name = CharField(source="parent.name", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -103,7 +110,7 @@ class UserSerializer(ModelSerializer):
|
||||||
|
|
||||||
is_superuser = BooleanField(read_only=True)
|
is_superuser = BooleanField(read_only=True)
|
||||||
avatar = CharField(read_only=True)
|
avatar = CharField(read_only=True)
|
||||||
attributes = JSONDictField(required=False)
|
attributes = JSONField(validators=[is_dict], required=False)
|
||||||
groups = PrimaryKeyRelatedField(
|
groups = PrimaryKeyRelatedField(
|
||||||
allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all(), default=list
|
allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all(), default=list
|
||||||
)
|
)
|
||||||
|
@ -220,7 +227,7 @@ class UserSelfSerializer(ModelSerializer):
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_settings(self, user: User) -> dict[str, Any]:
|
def get_settings(self, user: User) -> dict[str, Any]:
|
||||||
"""Get user settings with brand and group settings applied"""
|
"""Get user settings with tenant and group settings applied"""
|
||||||
return user.group_attributes(self._context["request"]).get("settings", {})
|
return user.group_attributes(self._context["request"]).get("settings", {})
|
||||||
|
|
||||||
def get_system_permissions(self, user: User) -> list[str]:
|
def get_system_permissions(self, user: User) -> list[str]:
|
||||||
|
@ -381,11 +388,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||||
return User.objects.all().exclude(pk=get_anonymous_user().pk)
|
return User.objects.all().exclude(pk=get_anonymous_user().pk)
|
||||||
|
|
||||||
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
|
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
|
||||||
"""Create a recovery link (when the current brand has a recovery flow set),
|
"""Create a recovery link (when the current tenant has a recovery flow set),
|
||||||
that can either be shown to an admin or sent to the user directly"""
|
that can either be shown to an admin or sent to the user directly"""
|
||||||
brand: Brand = self.request._request.brand
|
tenant: Tenant = self.request._request.tenant
|
||||||
# Check that there is a recovery flow, if not return an error
|
# Check that there is a recovery flow, if not return an error
|
||||||
flow = brand.flow_recovery
|
flow = tenant.flow_recovery
|
||||||
if not flow:
|
if not flow:
|
||||||
LOGGER.debug("No recovery flow set")
|
LOGGER.debug("No recovery flow set")
|
||||||
return None, None
|
return None, None
|
||||||
|
@ -617,7 +624,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||||
@action(detail=True, methods=["POST"])
|
@action(detail=True, methods=["POST"])
|
||||||
def impersonate(self, request: Request, pk: int) -> Response:
|
def impersonate(self, request: Request, pk: int) -> Response:
|
||||||
"""Impersonate a user"""
|
"""Impersonate a user"""
|
||||||
if not request.tenant.impersonation:
|
if not CONFIG.get_bool("impersonation"):
|
||||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||||
return Response(status=401)
|
return Response(status=401)
|
||||||
if not request.user.has_perm("impersonate"):
|
if not request.user.has_perm("impersonate"):
|
||||||
|
|
|
@ -2,10 +2,7 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
from rest_framework.fields import CharField, IntegerField, JSONField
|
||||||
from drf_spectacular.plumbing import build_basic_type
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField
|
|
||||||
from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError
|
from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,21 +13,6 @@ def is_dict(value: Any):
|
||||||
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
|
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
|
||||||
|
|
||||||
|
|
||||||
class JSONDictField(JSONField):
|
|
||||||
"""JSON Field which only allows dictionaries"""
|
|
||||||
|
|
||||||
default_validators = [is_dict]
|
|
||||||
|
|
||||||
|
|
||||||
class JSONExtension(OpenApiSerializerFieldExtension):
|
|
||||||
"""Generate API Schema for JSON fields as"""
|
|
||||||
|
|
||||||
target_class = "authentik.core.api.utils.JSONDictField"
|
|
||||||
|
|
||||||
def map_serializer_field(self, auto_schema, direction):
|
|
||||||
return build_basic_type(OpenApiTypes.OBJECT)
|
|
||||||
|
|
||||||
|
|
||||||
class PassiveSerializer(Serializer):
|
class PassiveSerializer(Serializer):
|
||||||
"""Base serializer class which doesn't implement create/update methods"""
|
"""Base serializer class which doesn't implement create/update methods"""
|
||||||
|
|
||||||
|
@ -44,7 +26,7 @@ class PassiveSerializer(Serializer):
|
||||||
class PropertyMappingPreviewSerializer(PassiveSerializer):
|
class PropertyMappingPreviewSerializer(PassiveSerializer):
|
||||||
"""Preview how the current user is mapped via the property mappings selected in a provider"""
|
"""Preview how the current user is mapped via the property mappings selected in a provider"""
|
||||||
|
|
||||||
preview = JSONDictField(read_only=True)
|
preview = JSONField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class MetaNameSerializer(PassiveSerializer):
|
class MetaNameSerializer(PassiveSerializer):
|
||||||
|
@ -74,7 +56,6 @@ class TypeCreateSerializer(PassiveSerializer):
|
||||||
description = CharField(required=True)
|
description = CharField(required=True)
|
||||||
component = CharField(required=True)
|
component = CharField(required=True)
|
||||||
model_name = CharField(required=True)
|
model_name = CharField(required=True)
|
||||||
requires_enterprise = BooleanField(default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class CacheSerializer(PassiveSerializer):
|
class CacheSerializer(PassiveSerializer):
|
||||||
|
|
|
@ -13,18 +13,18 @@ class AuthentikCoreConfig(ManagedAppConfig):
|
||||||
mountpoint = ""
|
mountpoint = ""
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
def reconcile_global_load_core_signals(self):
|
def reconcile_load_core_signals(self):
|
||||||
"""Load core signals"""
|
"""Load core signals"""
|
||||||
self.import_module("authentik.core.signals")
|
self.import_module("authentik.core.signals")
|
||||||
|
|
||||||
def reconcile_global_debug_worker_hook(self):
|
def reconcile_debug_worker_hook(self):
|
||||||
"""Dispatch startup tasks inline when debugging"""
|
"""Dispatch startup tasks inline when debugging"""
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
from authentik.root.celery import worker_ready_hook
|
from authentik.root.celery import worker_ready_hook
|
||||||
|
|
||||||
worker_ready_hook()
|
worker_ready_hook()
|
||||||
|
|
||||||
def reconcile_tenant_source_inbuilt(self):
|
def reconcile_source_inbuilt(self):
|
||||||
"""Reconcile inbuilt source"""
|
"""Reconcile inbuilt source"""
|
||||||
from authentik.core.models import Source
|
from authentik.core.models import Source
|
||||||
|
|
||||||
|
|
|
@ -1,29 +1,22 @@
|
||||||
"""Channels base classes"""
|
"""Channels base classes"""
|
||||||
from channels.db import database_sync_to_async
|
|
||||||
from channels.exceptions import DenyConnection
|
from channels.exceptions import DenyConnection
|
||||||
|
from channels.generic.websocket import JsonWebsocketConsumer
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.api.authentication import bearer_auth
|
from authentik.api.authentication import bearer_auth
|
||||||
|
from authentik.core.models import User
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class TokenOutpostMiddleware:
|
class AuthJsonConsumer(JsonWebsocketConsumer):
|
||||||
"""Authorize a client with a token"""
|
"""Authorize a client with a token"""
|
||||||
|
|
||||||
def __init__(self, inner):
|
user: User
|
||||||
self.inner = inner
|
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
def connect(self):
|
||||||
scope = dict(scope)
|
headers = dict(self.scope["headers"])
|
||||||
await self.auth(scope)
|
|
||||||
return await self.inner(scope, receive, send)
|
|
||||||
|
|
||||||
@database_sync_to_async
|
|
||||||
def auth(self, scope):
|
|
||||||
"""Authenticate request from header"""
|
|
||||||
headers = dict(scope["headers"])
|
|
||||||
if b"authorization" not in headers:
|
if b"authorization" not in headers:
|
||||||
LOGGER.warning("WS Request without authorization header")
|
LOGGER.warning("WS Request without authorization header")
|
||||||
raise DenyConnection()
|
raise DenyConnection()
|
||||||
|
@ -39,4 +32,4 @@ class TokenOutpostMiddleware:
|
||||||
LOGGER.warning("Failed to authenticate", exc=exc)
|
LOGGER.warning("Failed to authenticate", exc=exc)
|
||||||
raise DenyConnection()
|
raise DenyConnection()
|
||||||
|
|
||||||
scope["user"] = user
|
self.user = user
|
||||||
|
|
|
@ -1,20 +1,13 @@
|
||||||
"""Run bootstrap tasks"""
|
"""Run bootstrap tasks"""
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django_tenants.utils import get_public_schema_name
|
|
||||||
|
|
||||||
from authentik.root.celery import _get_startup_tasks_all_tenants, _get_startup_tasks_default_tenant
|
from authentik.root.celery import _get_startup_tasks
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""Run bootstrap tasks to ensure certain objects are created"""
|
"""Run bootstrap tasks to ensure certain objects are created"""
|
||||||
|
|
||||||
def handle(self, **options):
|
def handle(self, **options):
|
||||||
for task in _get_startup_tasks_default_tenant():
|
tasks = _get_startup_tasks()
|
||||||
with Tenant.objects.get(schema_name=get_public_schema_name()):
|
for task in tasks:
|
||||||
task()
|
|
||||||
|
|
||||||
for task in _get_startup_tasks_all_tenants():
|
|
||||||
for tenant in Tenant.objects.filter(ready=True):
|
|
||||||
with tenant:
|
|
||||||
task()
|
task()
|
||||||
|
|
|
@ -4,8 +4,6 @@ from django.contrib.auth.management import create_permissions
|
||||||
from django.core.management.base import BaseCommand, no_translations
|
from django.core.management.base import BaseCommand, no_translations
|
||||||
from guardian.management import create_anonymous_user
|
from guardian.management import create_anonymous_user
|
||||||
|
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""Repair missing permissions"""
|
"""Repair missing permissions"""
|
||||||
|
@ -13,8 +11,6 @@ class Command(BaseCommand):
|
||||||
@no_translations
|
@no_translations
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Check permissions for all apps"""
|
"""Check permissions for all apps"""
|
||||||
for tenant in Tenant.objects.filter(ready=True):
|
|
||||||
with tenant:
|
|
||||||
for app in apps.get_app_configs():
|
for app in apps.get_app_configs():
|
||||||
self.stdout.write(f"Checking app {app.name} ({app.label})\n")
|
self.stdout.write(f"Checking app {app.name} ({app.label})\n")
|
||||||
create_permissions(app, verbosity=0)
|
create_permissions(app, verbosity=0)
|
||||||
|
|
|
@ -30,6 +30,7 @@ from authentik.lib.models import (
|
||||||
DomainlessFormattedURLValidator,
|
DomainlessFormattedURLValidator,
|
||||||
SerializerModel,
|
SerializerModel,
|
||||||
)
|
)
|
||||||
|
from authentik.lib.utils.http import get_client_ip
|
||||||
from authentik.policies.models import PolicyBindingModel
|
from authentik.policies.models import PolicyBindingModel
|
||||||
from authentik.root.install_id import get_install_id
|
from authentik.root.install_id import get_install_id
|
||||||
|
|
||||||
|
@ -201,8 +202,8 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||||
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
||||||
including the users attributes"""
|
including the users attributes"""
|
||||||
final_attributes = {}
|
final_attributes = {}
|
||||||
if request and hasattr(request, "brand"):
|
if request and hasattr(request, "tenant"):
|
||||||
always_merger.merge(final_attributes, request.brand.attributes)
|
always_merger.merge(final_attributes, request.tenant.attributes)
|
||||||
for group in self.all_groups().order_by("name"):
|
for group in self.all_groups().order_by("name"):
|
||||||
always_merger.merge(final_attributes, group.attributes)
|
always_merger.merge(final_attributes, group.attributes)
|
||||||
always_merger.merge(final_attributes, self.attributes)
|
always_merger.merge(final_attributes, self.attributes)
|
||||||
|
@ -261,7 +262,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
LOGGER.warning("Failed to get default locale", exc=exc)
|
LOGGER.warning("Failed to get default locale", exc=exc)
|
||||||
if request:
|
if request:
|
||||||
return request.brand.locale
|
return request.tenant.locale
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -516,7 +517,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon_url(self) -> Optional[str]:
|
def get_icon(self) -> Optional[str]:
|
||||||
"""Get the URL to the Icon. If the name is /static or
|
"""Get the URL to the Icon. If the name is /static or
|
||||||
starts with http it is returned as-is"""
|
starts with http it is returned as-is"""
|
||||||
if not self.icon:
|
if not self.icon:
|
||||||
|
@ -747,14 +748,12 @@ class AuthenticatedSession(ExpiringModel):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
|
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
|
||||||
"""Create a new session from a http request"""
|
"""Create a new session from a http request"""
|
||||||
from authentik.root.middleware import ClientIPMiddleware
|
|
||||||
|
|
||||||
if not hasattr(request, "session") or not request.session.session_key:
|
if not hasattr(request, "session") or not request.session.session_key:
|
||||||
return None
|
return None
|
||||||
return AuthenticatedSession(
|
return AuthenticatedSession(
|
||||||
session_key=request.session.session_key,
|
session_key=request.session.session_key,
|
||||||
user=user,
|
user=user,
|
||||||
last_ip=ClientIPMiddleware.get_client_ip(request),
|
last_ip=get_client_ip(request),
|
||||||
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||||
expires=request.session.get_expiry_date(),
|
expires=request.session.get_expiry_date(),
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
window.authentik = {
|
window.authentik = {
|
||||||
locale: "{{ LANGUAGE_CODE }}",
|
locale: "{{ LANGUAGE_CODE }}",
|
||||||
config: JSON.parse('{{ config_json|escapejs }}'),
|
config: JSON.parse('{{ config_json|escapejs }}'),
|
||||||
brand: JSON.parse('{{ brand_json|escapejs }}'),
|
tenant: JSON.parse('{{ tenant_json|escapejs }}'),
|
||||||
versionFamily: "{{ version_family }}",
|
versionFamily: "{{ version_family }}",
|
||||||
versionSubdomain: "{{ version_subdomain }}",
|
versionSubdomain: "{{ version_subdomain }}",
|
||||||
build: "{{ build }}",
|
build: "{{ build }}",
|
||||||
|
|
|
@ -7,9 +7,9 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
|
<title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
|
||||||
<link rel="icon" href="{{ brand.branding_favicon }}">
|
<link rel="icon" href="{{ tenant.branding_favicon }}">
|
||||||
<link rel="shortcut icon" href="{{ brand.branding_favicon }}">
|
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans 'End session' %} - {{ brand.branding_title }}
|
{% trans 'End session' %} - {{ tenant.branding_title }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card_title %}
|
{% block card_title %}
|
||||||
|
@ -16,7 +16,7 @@ You've logged out of {{ application }}.
|
||||||
{% block card %}
|
{% block card %}
|
||||||
<form method="POST" class="pf-c-form">
|
<form method="POST" class="pf-c-form">
|
||||||
<p>
|
<p>
|
||||||
{% blocktrans with application=application.name branding_title=brand.branding_title %}
|
{% blocktrans with application=application.name branding_title=tenant.branding_title %}
|
||||||
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account.
|
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
@ -26,7 +26,7 @@ You've logged out of {{ application }}.
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
|
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
|
||||||
{% blocktrans with branding_title=brand.branding_title %}
|
{% blocktrans with branding_title=tenant.branding_title %}
|
||||||
Log out of {{ branding_title }}
|
Log out of {{ branding_title }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{{ brand.branding_title }}
|
{{ tenant.branding_title }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card_title %}
|
{% block card_title %}
|
||||||
|
|
|
@ -27,7 +27,7 @@ window.authentik.flow = {
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container></ak-message-container>
|
<ak-message-container></ak-message-container>
|
||||||
<ak-flow-executor flowSlug="{{ flow.slug }}">
|
<ak-flow-executor>
|
||||||
<ak-loading></ak-loading>
|
<ak-loading></ak-loading>
|
||||||
</ak-flow-executor>
|
</ak-flow-executor>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -44,14 +44,28 @@
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="pf-c-background-image">
|
<div class="pf-c-background-image">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
||||||
|
<filter id="image_overlay">
|
||||||
|
<feColorMatrix in="SourceGraphic" type="matrix" values="1.3 0 0 0 0 0 1.3 0 0 0 0 0 1.3 0 0 0 0 0 1 0" />
|
||||||
|
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
||||||
|
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
||||||
|
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
||||||
|
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
||||||
|
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
||||||
|
</feComponentTransfer>
|
||||||
|
</filter>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<ak-message-container></ak-message-container>
|
<ak-message-container></ak-message-container>
|
||||||
<div class="pf-c-login stacked">
|
<div class="pf-c-login">
|
||||||
<div class="ak-login-container">
|
<div class="ak-login-container">
|
||||||
<main class="pf-c-login__main">
|
<header class="pf-c-login__header">
|
||||||
<div class="pf-c-login__main-header pf-c-brand ak-brand">
|
<div class="pf-c-brand ak-brand">
|
||||||
<img src="{{ brand.branding_logo }}" alt="authentik Logo" />
|
<img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
{% block main_container %}
|
||||||
|
<main class="pf-c-login__main">
|
||||||
<header class="pf-c-login__main-header">
|
<header class="pf-c-login__main-header">
|
||||||
<h1 class="pf-c-title pf-m-3xl">
|
<h1 class="pf-c-title pf-m-3xl">
|
||||||
{% block card_title %}
|
{% block card_title %}
|
||||||
|
@ -63,6 +77,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{% endblock %}
|
||||||
<footer class="pf-c-login__footer">
|
<footer class="pf-c-login__footer">
|
||||||
<ul class="pf-c-list pf-m-inline">
|
<ul class="pf-c-list pf-m-inline">
|
||||||
{% for link in footer_links %}
|
{% for link in footer_links %}
|
||||||
|
|
|
@ -3,10 +3,10 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from authentik.brands.models import Brand
|
|
||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
class TestApplicationsViews(FlowTestCase):
|
class TestApplicationsViews(FlowTestCase):
|
||||||
|
@ -21,9 +21,9 @@ class TestApplicationsViews(FlowTestCase):
|
||||||
def test_check_redirect(self):
|
def test_check_redirect(self):
|
||||||
"""Test redirect"""
|
"""Test redirect"""
|
||||||
empty_flow = create_test_flow()
|
empty_flow = create_test_flow()
|
||||||
brand: Brand = create_test_brand()
|
tenant: Tenant = create_test_tenant()
|
||||||
brand.flow_authentication = empty_flow
|
tenant.flow_authentication = empty_flow
|
||||||
brand.save()
|
tenant.save()
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse(
|
||||||
"authentik_core:application-launch",
|
"authentik_core:application-launch",
|
||||||
|
@ -45,9 +45,9 @@ class TestApplicationsViews(FlowTestCase):
|
||||||
"""Test redirect"""
|
"""Test redirect"""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
empty_flow = create_test_flow()
|
empty_flow = create_test_flow()
|
||||||
brand: Brand = create_test_brand()
|
tenant: Tenant = create_test_tenant()
|
||||||
brand.flow_authentication = empty_flow
|
tenant.flow_authentication = empty_flow
|
||||||
brand.save()
|
tenant.save()
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse(
|
||||||
"authentik_core:application-launch",
|
"authentik_core:application-launch",
|
||||||
|
|
|
@ -6,7 +6,7 @@ from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
from authentik.tenants.utils import get_current_tenant
|
from authentik.lib.config import CONFIG
|
||||||
|
|
||||||
|
|
||||||
class TestImpersonation(APITestCase):
|
class TestImpersonation(APITestCase):
|
||||||
|
@ -56,11 +56,9 @@ class TestImpersonation(APITestCase):
|
||||||
response_body = loads(response.content.decode())
|
response_body = loads(response.content.decode())
|
||||||
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
||||||
|
|
||||||
|
@CONFIG.patch("impersonation", False)
|
||||||
def test_impersonate_disabled(self):
|
def test_impersonate_disabled(self):
|
||||||
"""test impersonation that is disabled"""
|
"""test impersonation that is disabled"""
|
||||||
tenant = get_current_tenant()
|
|
||||||
tenant.impersonation = False
|
|
||||||
tenant.save()
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|
|
@ -7,7 +7,6 @@ from django.core.cache import cache
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.brands.models import Brand
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
|
@ -15,10 +14,11 @@ from authentik.core.models import (
|
||||||
User,
|
User,
|
||||||
UserTypes,
|
UserTypes,
|
||||||
)
|
)
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
class TestUsersAPI(APITestCase):
|
class TestUsersAPI(APITestCase):
|
||||||
|
@ -80,9 +80,9 @@ class TestUsersAPI(APITestCase):
|
||||||
def test_recovery(self):
|
def test_recovery(self):
|
||||||
"""Test user recovery link (no recovery flow set)"""
|
"""Test user recovery link (no recovery flow set)"""
|
||||||
flow = create_test_flow(FlowDesignation.RECOVERY)
|
flow = create_test_flow(FlowDesignation.RECOVERY)
|
||||||
brand: Brand = create_test_brand()
|
tenant: Tenant = create_test_tenant()
|
||||||
brand.flow_recovery = flow
|
tenant.flow_recovery = flow
|
||||||
brand.save()
|
tenant.save()
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
|
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
|
||||||
|
@ -108,9 +108,9 @@ class TestUsersAPI(APITestCase):
|
||||||
self.user.email = "foo@bar.baz"
|
self.user.email = "foo@bar.baz"
|
||||||
self.user.save()
|
self.user.save()
|
||||||
flow = create_test_flow(designation=FlowDesignation.RECOVERY)
|
flow = create_test_flow(designation=FlowDesignation.RECOVERY)
|
||||||
brand: Brand = create_test_brand()
|
tenant: Tenant = create_test_tenant()
|
||||||
brand.flow_recovery = flow
|
tenant.flow_recovery = flow
|
||||||
brand.save()
|
tenant.save()
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
|
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
|
||||||
|
@ -122,9 +122,9 @@ class TestUsersAPI(APITestCase):
|
||||||
self.user.email = "foo@bar.baz"
|
self.user.email = "foo@bar.baz"
|
||||||
self.user.save()
|
self.user.save()
|
||||||
flow = create_test_flow(FlowDesignation.RECOVERY)
|
flow = create_test_flow(FlowDesignation.RECOVERY)
|
||||||
brand: Brand = create_test_brand()
|
tenant: Tenant = create_test_tenant()
|
||||||
brand.flow_recovery = flow
|
tenant.flow_recovery = flow
|
||||||
brand.save()
|
tenant.save()
|
||||||
|
|
||||||
stage = EmailStage.objects.create(name="email")
|
stage = EmailStage.objects.create(name="email")
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ from rest_framework.test import APITestCase
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.tenants.utils import get_current_tenant
|
|
||||||
|
|
||||||
|
|
||||||
class TestUsersAvatars(APITestCase):
|
class TestUsersAvatars(APITestCase):
|
||||||
|
@ -18,25 +17,18 @@ class TestUsersAvatars(APITestCase):
|
||||||
self.admin = create_test_admin_user()
|
self.admin = create_test_admin_user()
|
||||||
self.user = User.objects.create(username="test-user")
|
self.user = User.objects.create(username="test-user")
|
||||||
|
|
||||||
def set_avatar_mode(self, mode: str):
|
|
||||||
"""Set the avatar mode on the current tenant."""
|
|
||||||
tenant = get_current_tenant()
|
|
||||||
tenant.avatars = mode
|
|
||||||
tenant.save()
|
|
||||||
|
|
||||||
@CONFIG.patch("avatars", "none")
|
@CONFIG.patch("avatars", "none")
|
||||||
def test_avatars_none(self):
|
def test_avatars_none(self):
|
||||||
"""Test avatars none"""
|
"""Test avatars none"""
|
||||||
self.set_avatar_mode("none")
|
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = loads(response.content.decode())
|
body = loads(response.content.decode())
|
||||||
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
|
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
|
||||||
|
|
||||||
|
@CONFIG.patch("avatars", "gravatar")
|
||||||
def test_avatars_gravatar(self):
|
def test_avatars_gravatar(self):
|
||||||
"""Test avatars gravatar"""
|
"""Test avatars gravatar"""
|
||||||
self.set_avatar_mode("gravatar")
|
|
||||||
self.admin.email = "static@t.goauthentik.io"
|
self.admin.email = "static@t.goauthentik.io"
|
||||||
self.admin.save()
|
self.admin.save()
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
@ -53,27 +45,27 @@ class TestUsersAvatars(APITestCase):
|
||||||
body = loads(response.content.decode())
|
body = loads(response.content.decode())
|
||||||
self.assertIn("gravatar", body["user"]["avatar"])
|
self.assertIn("gravatar", body["user"]["avatar"])
|
||||||
|
|
||||||
|
@CONFIG.patch("avatars", "initials")
|
||||||
def test_avatars_initials(self):
|
def test_avatars_initials(self):
|
||||||
"""Test avatars initials"""
|
"""Test avatars initials"""
|
||||||
self.set_avatar_mode("initials")
|
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = loads(response.content.decode())
|
body = loads(response.content.decode())
|
||||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
||||||
|
|
||||||
|
@CONFIG.patch("avatars", "foo://%(username)s")
|
||||||
def test_avatars_custom(self):
|
def test_avatars_custom(self):
|
||||||
"""Test avatars custom"""
|
"""Test avatars custom"""
|
||||||
self.set_avatar_mode("foo://%(username)s")
|
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = loads(response.content.decode())
|
body = loads(response.content.decode())
|
||||||
self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}")
|
self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}")
|
||||||
|
|
||||||
|
@CONFIG.patch("avatars", "attributes.foo.avatar")
|
||||||
def test_avatars_attributes(self):
|
def test_avatars_attributes(self):
|
||||||
"""Test avatars attributes"""
|
"""Test avatars attributes"""
|
||||||
self.set_avatar_mode("attributes.foo.avatar")
|
|
||||||
self.admin.attributes = {"foo": {"avatar": "bar"}}
|
self.admin.attributes = {"foo": {"avatar": "bar"}}
|
||||||
self.admin.save()
|
self.admin.save()
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
@ -82,9 +74,9 @@ class TestUsersAvatars(APITestCase):
|
||||||
body = loads(response.content.decode())
|
body = loads(response.content.decode())
|
||||||
self.assertEqual(body["user"]["avatar"], "bar")
|
self.assertEqual(body["user"]["avatar"], "bar")
|
||||||
|
|
||||||
|
@CONFIG.patch("avatars", "attributes.foo.avatar,initials")
|
||||||
def test_avatars_fallback(self):
|
def test_avatars_fallback(self):
|
||||||
"""Test fallback"""
|
"""Test fallback"""
|
||||||
self.set_avatar_mode("attributes.foo.avatar,initials")
|
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
|
@ -3,12 +3,12 @@ from typing import Optional
|
||||||
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from authentik.brands.models import Brand
|
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.flows.models import Flow, FlowDesignation
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
def create_test_flow(
|
def create_test_flow(
|
||||||
|
@ -43,12 +43,12 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def create_test_brand(**kwargs) -> Brand:
|
def create_test_tenant(**kwargs) -> Tenant:
|
||||||
"""Generate a test brand, removing all other brands to make sure this one
|
"""Generate a test tenant, removing all other tenants to make sure this one
|
||||||
matches."""
|
matches."""
|
||||||
uid = generate_id(20)
|
uid = generate_id(20)
|
||||||
Brand.objects.all().delete()
|
Tenant.objects.all().delete()
|
||||||
return Brand.objects.create(domain=uid, default=True, **kwargs)
|
return Tenant.objects.create(domain=uid, default=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
||||||
|
|
|
@ -9,8 +9,8 @@ from rest_framework.request import Request
|
||||||
from authentik import get_build_hash
|
from authentik import get_build_hash
|
||||||
from authentik.admin.tasks import LOCAL_VERSION
|
from authentik.admin.tasks import LOCAL_VERSION
|
||||||
from authentik.api.v3.config import ConfigView
|
from authentik.api.v3.config import ConfigView
|
||||||
from authentik.brands.api import CurrentBrandSerializer
|
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
|
from authentik.tenants.api import CurrentTenantSerializer
|
||||||
|
|
||||||
|
|
||||||
class InterfaceView(TemplateView):
|
class InterfaceView(TemplateView):
|
||||||
|
@ -18,11 +18,10 @@ class InterfaceView(TemplateView):
|
||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
|
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
|
||||||
kwargs["brand_json"] = dumps(CurrentBrandSerializer(self.request.brand).data)
|
kwargs["tenant_json"] = dumps(CurrentTenantSerializer(self.request.tenant).data)
|
||||||
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
|
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
|
||||||
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
|
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
|
||||||
kwargs["build"] = get_build_hash()
|
kwargs["build"] = get_build_hash()
|
||||||
kwargs["url_kwargs"] = self.kwargs
|
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
||||||
verbose_name = "authentik Crypto"
|
verbose_name = "authentik Crypto"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
def reconcile_global_load_crypto_tasks(self):
|
def reconcile_load_crypto_tasks(self):
|
||||||
"""Load crypto tasks"""
|
"""Load crypto tasks"""
|
||||||
self.import_module("authentik.crypto.tasks")
|
self.import_module("authentik.crypto.tasks")
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def reconcile_tenant_managed_jwt_cert(self):
|
def reconcile_managed_jwt_cert(self):
|
||||||
"""Ensure managed JWT certificate"""
|
"""Ensure managed JWT certificate"""
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
||||||
):
|
):
|
||||||
self._create_update_cert()
|
self._create_update_cert()
|
||||||
|
|
||||||
def reconcile_tenant_self_signed(self):
|
def reconcile_self_signed(self):
|
||||||
"""Create self-signed keypair"""
|
"""Create self-signed keypair"""
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
"""Import certificate"""
|
"""Import certificate"""
|
||||||
from sys import exit as sys_exit
|
from sys import exit as sys_exit
|
||||||
|
|
||||||
from django.core.management.base import no_translations
|
from django.core.management.base import BaseCommand, no_translations
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.crypto.api import CertificateKeyPairSerializer
|
from authentik.crypto.api import CertificateKeyPairSerializer
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.tenants.management import TenantCommand
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class Command(TenantCommand):
|
class Command(BaseCommand):
|
||||||
"""Import certificate"""
|
"""Import certificate"""
|
||||||
|
|
||||||
@no_translations
|
@no_translations
|
||||||
def handle_per_tenant(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Import certificate"""
|
"""Import certificate"""
|
||||||
keypair = CertificateKeyPair.objects.filter(name=options["name"]).first()
|
keypair = CertificateKeyPair.objects.filter(name=options["name"]).first()
|
||||||
dirty = False
|
dirty = False
|
||||||
|
|
|
@ -2,11 +2,9 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
|
||||||
from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField
|
from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
@ -22,18 +20,6 @@ from authentik.enterprise.models import License, LicenseKey
|
||||||
from authentik.root.install_id import get_install_id
|
from authentik.root.install_id import get_install_id
|
||||||
|
|
||||||
|
|
||||||
class EnterpriseRequiredMixin:
|
|
||||||
"""Mixin to validate that a valid enterprise license
|
|
||||||
exists before allowing to safe the object"""
|
|
||||||
|
|
||||||
def validate(self, attrs: dict) -> dict:
|
|
||||||
"""Check that a valid license exists"""
|
|
||||||
total = LicenseKey.get_total()
|
|
||||||
if not total.is_valid():
|
|
||||||
raise ValidationError(_("Enterprise is required to create/update this object."))
|
|
||||||
return super().validate(attrs)
|
|
||||||
|
|
||||||
|
|
||||||
class LicenseSerializer(ModelSerializer):
|
class LicenseSerializer(ModelSerializer):
|
||||||
"""License Serializer"""
|
"""License Serializer"""
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,7 @@
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
|
|
||||||
|
|
||||||
class EnterpriseConfig(ManagedAppConfig):
|
class AuthentikEnterpriseConfig(ManagedAppConfig):
|
||||||
"""Base app config for all enterprise apps"""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikEnterpriseConfig(EnterpriseConfig):
|
|
||||||
"""Enterprise app config"""
|
"""Enterprise app config"""
|
||||||
|
|
||||||
name = "authentik.enterprise"
|
name = "authentik.enterprise"
|
||||||
|
@ -14,6 +10,6 @@ class AuthentikEnterpriseConfig(EnterpriseConfig):
|
||||||
verbose_name = "authentik Enterprise"
|
verbose_name = "authentik Enterprise"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
def reconcile_global_load_enterprise_signals(self):
|
def reconcile_load_enterprise_signals(self):
|
||||||
"""Load enterprise signals"""
|
"""Load enterprise signals"""
|
||||||
self.import_module("authentik.enterprise.signals")
|
self.import_module("authentik.enterprise.signals")
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
"""Enterprise license policies"""
|
"""Enterprise license policies"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from authentik.core.models import User, UserTypes
|
from authentik.core.models import User, UserTypes
|
||||||
from authentik.enterprise.models import LicenseKey
|
from authentik.enterprise.models import LicenseKey
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
|
@ -15,10 +13,10 @@ class EnterprisePolicyAccessView(PolicyAccessView):
|
||||||
def check_license(self):
|
def check_license(self):
|
||||||
"""Check license"""
|
"""Check license"""
|
||||||
if not LicenseKey.get_total().is_valid():
|
if not LicenseKey.get_total().is_valid():
|
||||||
return PolicyResult(False, _("Enterprise required to access this feature."))
|
return False
|
||||||
if self.request.user.type != UserTypes.INTERNAL:
|
if self.request.user.type != UserTypes.INTERNAL:
|
||||||
return PolicyResult(False, _("Feature only accessible for internal users."))
|
return False
|
||||||
return PolicyResult(True)
|
return True
|
||||||
|
|
||||||
def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
|
def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
|
||||||
user = user or self.request.user
|
user = user or self.request.user
|
||||||
|
@ -26,7 +24,7 @@ class EnterprisePolicyAccessView(PolicyAccessView):
|
||||||
request.http_request = self.request
|
request.http_request = self.request
|
||||||
result = super().user_has_access(user)
|
result = super().user_has_access(user)
|
||||||
enterprise_result = self.check_license()
|
enterprise_result = self.check_license()
|
||||||
if not enterprise_result.passing:
|
if not enterprise_result:
|
||||||
return enterprise_result
|
return enterprise_result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
@ -1,135 +0,0 @@
|
||||||
"""RAC Provider API Views"""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.urls import reverse
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
|
||||||
from rest_framework.fields import SerializerMethodField
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.serializers import ModelSerializer
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
|
||||||
from authentik.core.models import Provider
|
|
||||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
|
||||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
|
|
||||||
from authentik.enterprise.providers.rac.models import Endpoint
|
|
||||||
from authentik.policies.engine import PolicyEngine
|
|
||||||
from authentik.rbac.filters import ObjectFilter
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
def user_endpoint_cache_key(user_pk: str) -> str:
|
|
||||||
"""Cache key where endpoint list for user is saved"""
|
|
||||||
return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
|
||||||
"""Endpoint Serializer"""
|
|
||||||
|
|
||||||
provider_obj = RACProviderSerializer(source="provider", read_only=True)
|
|
||||||
launch_url = SerializerMethodField()
|
|
||||||
|
|
||||||
def get_launch_url(self, endpoint: Endpoint) -> Optional[str]:
|
|
||||||
"""Build actual launch URL (the provider itself does not have one, just
|
|
||||||
individual endpoints)"""
|
|
||||||
try:
|
|
||||||
# pylint: disable=no-member
|
|
||||||
return reverse(
|
|
||||||
"authentik_providers_rac:start",
|
|
||||||
kwargs={"app": endpoint.provider.application.slug, "endpoint": endpoint.pk},
|
|
||||||
)
|
|
||||||
except Provider.application.RelatedObjectDoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Endpoint
|
|
||||||
fields = [
|
|
||||||
"pk",
|
|
||||||
"name",
|
|
||||||
"provider",
|
|
||||||
"provider_obj",
|
|
||||||
"protocol",
|
|
||||||
"host",
|
|
||||||
"settings",
|
|
||||||
"property_mappings",
|
|
||||||
"auth_mode",
|
|
||||||
"launch_url",
|
|
||||||
"maximum_connections",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointViewSet(UsedByMixin, ModelViewSet):
|
|
||||||
"""Endpoint Viewset"""
|
|
||||||
|
|
||||||
queryset = Endpoint.objects.all()
|
|
||||||
serializer_class = EndpointSerializer
|
|
||||||
filterset_fields = ["name", "provider"]
|
|
||||||
search_fields = ["name", "protocol"]
|
|
||||||
ordering = ["name", "protocol"]
|
|
||||||
|
|
||||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
|
||||||
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
|
|
||||||
for backend in list(self.filter_backends):
|
|
||||||
if backend == ObjectFilter:
|
|
||||||
continue
|
|
||||||
queryset = backend().filter_queryset(self.request, queryset, self)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def _get_allowed_endpoints(self, queryset: QuerySet) -> list[Endpoint]:
|
|
||||||
endpoints = []
|
|
||||||
for endpoint in queryset:
|
|
||||||
engine = PolicyEngine(endpoint, self.request.user, self.request)
|
|
||||||
engine.build()
|
|
||||||
if engine.passing:
|
|
||||||
endpoints.append(endpoint)
|
|
||||||
return endpoints
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
parameters=[
|
|
||||||
OpenApiParameter(
|
|
||||||
"search",
|
|
||||||
OpenApiTypes.STR,
|
|
||||||
),
|
|
||||||
OpenApiParameter(
|
|
||||||
name="superuser_full_list",
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
type=OpenApiTypes.BOOL,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
responses={
|
|
||||||
200: EndpointSerializer(many=True),
|
|
||||||
400: OpenApiResponse(description="Bad request"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def list(self, request: Request, *args, **kwargs) -> Response:
|
|
||||||
"""List accessible endpoints"""
|
|
||||||
should_cache = request.GET.get("search", "") == ""
|
|
||||||
|
|
||||||
superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true"
|
|
||||||
if superuser_full_list and request.user.is_superuser:
|
|
||||||
return super().list(request)
|
|
||||||
|
|
||||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
|
||||||
self.paginate_queryset(queryset)
|
|
||||||
|
|
||||||
allowed_endpoints = []
|
|
||||||
if not should_cache:
|
|
||||||
allowed_endpoints = self._get_allowed_endpoints(queryset)
|
|
||||||
if should_cache:
|
|
||||||
allowed_endpoints = cache.get(user_endpoint_cache_key(self.request.user.pk))
|
|
||||||
if not allowed_endpoints:
|
|
||||||
LOGGER.debug("Caching allowed endpoint list")
|
|
||||||
allowed_endpoints = self._get_allowed_endpoints(queryset)
|
|
||||||
cache.set(
|
|
||||||
user_endpoint_cache_key(self.request.user.pk),
|
|
||||||
allowed_endpoints,
|
|
||||||
timeout=86400,
|
|
||||||
)
|
|
||||||
serializer = self.get_serializer(allowed_endpoints, many=True)
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
|
@ -1,49 +0,0 @@
|
||||||
"""RAC Provider API Views"""
|
|
||||||
from django_filters.filters import AllValuesMultipleFilter
|
|
||||||
from django_filters.filterset import FilterSet
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from drf_spectacular.utils import extend_schema_field
|
|
||||||
from rest_framework.fields import CharField
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
|
||||||
from authentik.core.api.utils import JSONDictField
|
|
||||||
from authentik.enterprise.providers.rac.models import RACPropertyMapping
|
|
||||||
|
|
||||||
|
|
||||||
class RACPropertyMappingSerializer(PropertyMappingSerializer):
|
|
||||||
"""RACPropertyMapping Serializer"""
|
|
||||||
|
|
||||||
static_settings = JSONDictField()
|
|
||||||
expression = CharField(allow_blank=True, required=False)
|
|
||||||
|
|
||||||
def validate_expression(self, expression: str) -> str:
|
|
||||||
"""Test Syntax"""
|
|
||||||
if expression == "":
|
|
||||||
return expression
|
|
||||||
return super().validate_expression(expression)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = RACPropertyMapping
|
|
||||||
fields = PropertyMappingSerializer.Meta.fields + ["static_settings"]
|
|
||||||
|
|
||||||
|
|
||||||
class RACPropertyMappingFilter(FilterSet):
|
|
||||||
"""Filter for RACPropertyMapping"""
|
|
||||||
|
|
||||||
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = RACPropertyMapping
|
|
||||||
fields = ["name", "managed"]
|
|
||||||
|
|
||||||
|
|
||||||
class RACPropertyMappingViewSet(UsedByMixin, ModelViewSet):
|
|
||||||
"""RACPropertyMapping Viewset"""
|
|
||||||
|
|
||||||
queryset = RACPropertyMapping.objects.all()
|
|
||||||
serializer_class = RACPropertyMappingSerializer
|
|
||||||
search_fields = ["name"]
|
|
||||||
ordering = ["name"]
|
|
||||||
filterset_class = RACPropertyMappingFilter
|
|
|
@ -1,32 +0,0 @@
|
||||||
"""RAC Provider API Views"""
|
|
||||||
from rest_framework.fields import CharField, ListField
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from authentik.core.api.providers import ProviderSerializer
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
|
||||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
|
||||||
from authentik.enterprise.providers.rac.models import RACProvider
|
|
||||||
|
|
||||||
|
|
||||||
class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
|
|
||||||
"""RACProvider Serializer"""
|
|
||||||
|
|
||||||
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = RACProvider
|
|
||||||
fields = ProviderSerializer.Meta.fields + ["settings", "outpost_set", "connection_expiry"]
|
|
||||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
class RACProviderViewSet(UsedByMixin, ModelViewSet):
|
|
||||||
"""RACProvider Viewset"""
|
|
||||||
|
|
||||||
queryset = RACProvider.objects.all()
|
|
||||||
serializer_class = RACProviderSerializer
|
|
||||||
filterset_fields = {
|
|
||||||
"application": ["isnull"],
|
|
||||||
"name": ["iexact"],
|
|
||||||
}
|
|
||||||
search_fields = ["name"]
|
|
||||||
ordering = ["name"]
|
|
|
@ -1,17 +0,0 @@
|
||||||
"""RAC app config"""
|
|
||||||
from authentik.enterprise.apps import EnterpriseConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikEnterpriseProviderRAC(EnterpriseConfig):
|
|
||||||
"""authentik enterprise rac app config"""
|
|
||||||
|
|
||||||
name = "authentik.enterprise.providers.rac"
|
|
||||||
label = "authentik_providers_rac"
|
|
||||||
verbose_name = "authentik Enterprise.Providers.RAC"
|
|
||||||
default = True
|
|
||||||
mountpoint = ""
|
|
||||||
ws_mountpoint = "authentik.enterprise.providers.rac.urls"
|
|
||||||
|
|
||||||
def reconcile_global_load_rac_signals(self):
|
|
||||||
"""Load rac signals"""
|
|
||||||
self.import_module("authentik.enterprise.providers.rac.signals")
|
|
|
@ -1,163 +0,0 @@
|
||||||
"""RAC Client consumer"""
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from channels.db import database_sync_to_async
|
|
||||||
from channels.exceptions import ChannelFull, DenyConnection
|
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
||||||
from django.http.request import QueryDict
|
|
||||||
from structlog.stdlib import BoundLogger, get_logger
|
|
||||||
|
|
||||||
from authentik.enterprise.providers.rac.models import ConnectionToken, RACProvider
|
|
||||||
from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE
|
|
||||||
from authentik.outposts.models import Outpost, OutpostState, OutpostType
|
|
||||||
|
|
||||||
# Global broadcast group, which messages are sent to when the outpost connects back
|
|
||||||
# to authentik for a specific connection
|
|
||||||
# The `RACClientConsumer` consumer adds itself to this group on connection,
|
|
||||||
# and removes itself once it has been assigned a specific outpost channel
|
|
||||||
RAC_CLIENT_GROUP = "group_enterprise_rac_client"
|
|
||||||
# A group for all connections in a given authentik session ID
|
|
||||||
# A disconnect message is sent to this group when the session expires/is deleted
|
|
||||||
RAC_CLIENT_GROUP_SESSION = "group_enterprise_rac_client_%(session)s"
|
|
||||||
# A group for all connections with a specific token, which in almost all cases
|
|
||||||
# is just one connection, however this is used to disconnect the connection
|
|
||||||
# when the token is deleted
|
|
||||||
RAC_CLIENT_GROUP_TOKEN = "group_enterprise_rac_token_%(token)s" # nosec
|
|
||||||
|
|
||||||
# Step 1: Client connects to this websocket endpoint
|
|
||||||
# Step 2: We prepare all the connection args for Guac
|
|
||||||
# Step 3: Send a websocket message to a single outpost that has this provider assigned
|
|
||||||
# (Currently sending to all of them)
|
|
||||||
# (Should probably do different load balancing algorithms)
|
|
||||||
# Step 4: Outpost creates a websocket connection back to authentik
|
|
||||||
# with /ws/outpost_rac/<our_channel_id>/
|
|
||||||
# Step 5: This consumer transfers data between the two channels
|
|
||||||
|
|
||||||
|
|
||||||
class RACClientConsumer(AsyncWebsocketConsumer):
|
|
||||||
"""RAC client consumer the browser connects to"""
|
|
||||||
|
|
||||||
dest_channel_id: str = ""
|
|
||||||
provider: RACProvider
|
|
||||||
token: ConnectionToken
|
|
||||||
logger: BoundLogger
|
|
||||||
|
|
||||||
async def connect(self):
|
|
||||||
await self.accept("guacamole")
|
|
||||||
await self.channel_layer.group_add(RAC_CLIENT_GROUP, self.channel_name)
|
|
||||||
await self.channel_layer.group_add(
|
|
||||||
RAC_CLIENT_GROUP_SESSION % {"session": self.scope["session"].session_key},
|
|
||||||
self.channel_name,
|
|
||||||
)
|
|
||||||
await self.init_outpost_connection()
|
|
||||||
|
|
||||||
async def disconnect(self, code):
|
|
||||||
self.logger.debug("Disconnecting")
|
|
||||||
# Tell the outpost we're disconnecting
|
|
||||||
await self.channel_layer.send(
|
|
||||||
self.dest_channel_id,
|
|
||||||
{
|
|
||||||
"type": "event.disconnect",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@database_sync_to_async
|
|
||||||
def init_outpost_connection(self):
|
|
||||||
"""Initialize guac connection settings"""
|
|
||||||
self.token = ConnectionToken.filter_not_expired(
|
|
||||||
token=self.scope["url_route"]["kwargs"]["token"]
|
|
||||||
).first()
|
|
||||||
if not self.token:
|
|
||||||
raise DenyConnection()
|
|
||||||
self.provider = self.token.provider
|
|
||||||
params = self.token.get_settings()
|
|
||||||
self.logger = get_logger().bind(
|
|
||||||
endpoint=self.token.endpoint.name, user=self.scope["user"].username
|
|
||||||
)
|
|
||||||
msg = {
|
|
||||||
"type": "event.provider.specific",
|
|
||||||
"sub_type": "init_connection",
|
|
||||||
"dest_channel_id": self.channel_name,
|
|
||||||
"params": params,
|
|
||||||
"protocol": self.token.endpoint.protocol,
|
|
||||||
}
|
|
||||||
query = QueryDict(self.scope["query_string"].decode())
|
|
||||||
for key in ["screen_width", "screen_height", "screen_dpi", "audio"]:
|
|
||||||
value = query.get(key, None)
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
msg[key] = str(value)
|
|
||||||
outposts = Outpost.objects.filter(
|
|
||||||
type=OutpostType.RAC,
|
|
||||||
providers__in=[self.provider],
|
|
||||||
)
|
|
||||||
if not outposts.exists():
|
|
||||||
self.logger.warning("Provider has no outpost")
|
|
||||||
raise DenyConnection()
|
|
||||||
for outpost in outposts:
|
|
||||||
# Sort all states for the outpost by connection count
|
|
||||||
states = sorted(
|
|
||||||
OutpostState.for_outpost(outpost),
|
|
||||||
key=lambda state: int(state.args.get("active_connections", 0)),
|
|
||||||
)
|
|
||||||
if len(states) < 1:
|
|
||||||
continue
|
|
||||||
self.logger.debug("Sending out connection broadcast")
|
|
||||||
async_to_sync(self.channel_layer.group_send)(
|
|
||||||
OUTPOST_GROUP_INSTANCE % {"outpost_pk": str(outpost.pk), "instance": states[0].uid},
|
|
||||||
msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def receive(self, text_data=None, bytes_data=None):
|
|
||||||
"""Mirror data received from client to the dest_channel_id
|
|
||||||
which is the channel talking to guacd"""
|
|
||||||
if self.dest_channel_id == "":
|
|
||||||
return
|
|
||||||
if self.token.is_expired:
|
|
||||||
await self.event_disconnect({"reason": "token_expiry"})
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await self.channel_layer.send(
|
|
||||||
self.dest_channel_id,
|
|
||||||
{
|
|
||||||
"type": "event.send",
|
|
||||||
"text_data": text_data,
|
|
||||||
"bytes_data": bytes_data,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except ChannelFull:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def event_outpost_connected(self, event: dict):
|
|
||||||
"""Handle event broadcasted from outpost consumer, and check if they
|
|
||||||
created a connection for us"""
|
|
||||||
outpost_channel = event.get("outpost_channel")
|
|
||||||
if event.get("client_channel") != self.channel_name:
|
|
||||||
return
|
|
||||||
if self.dest_channel_id != "":
|
|
||||||
# We've already selected an outpost channel, so tell the other channel to disconnect
|
|
||||||
# This should never happen since we remove ourselves from the broadcast group
|
|
||||||
await self.channel_layer.send(
|
|
||||||
outpost_channel,
|
|
||||||
{
|
|
||||||
"type": "event.disconnect",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self.logger.debug("Connected to a single outpost instance")
|
|
||||||
self.dest_channel_id = outpost_channel
|
|
||||||
# Since we have a specific outpost channel now, we can remove
|
|
||||||
# ourselves from the global broadcast group
|
|
||||||
await self.channel_layer.group_discard(RAC_CLIENT_GROUP, self.channel_name)
|
|
||||||
|
|
||||||
async def event_send(self, event: dict):
|
|
||||||
"""Handler called by outpost websocket that sends data to this specific
|
|
||||||
client connection"""
|
|
||||||
if self.token.is_expired:
|
|
||||||
await self.event_disconnect({"reason": "token_expiry"})
|
|
||||||
return
|
|
||||||
await self.send(text_data=event.get("text_data"), bytes_data=event.get("bytes_data"))
|
|
||||||
|
|
||||||
async def event_disconnect(self, event: dict):
|
|
||||||
"""Disconnect when the session ends"""
|
|
||||||
self.logger.info("Disconnecting RAC connection", reason=event.get("reason"))
|
|
||||||
await self.close()
|
|
|
@ -1,48 +0,0 @@
|
||||||
"""RAC consumer"""
|
|
||||||
from channels.exceptions import ChannelFull
|
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
||||||
|
|
||||||
from authentik.enterprise.providers.rac.consumer_client import RAC_CLIENT_GROUP
|
|
||||||
|
|
||||||
|
|
||||||
class RACOutpostConsumer(AsyncWebsocketConsumer):
|
|
||||||
"""Consumer the outpost connects to, to send specific data back to a client connection"""
|
|
||||||
|
|
||||||
dest_channel_id: str
|
|
||||||
|
|
||||||
async def connect(self):
|
|
||||||
self.dest_channel_id = self.scope["url_route"]["kwargs"]["channel"]
|
|
||||||
await self.accept()
|
|
||||||
await self.channel_layer.group_send(
|
|
||||||
RAC_CLIENT_GROUP,
|
|
||||||
{
|
|
||||||
"type": "event.outpost.connected",
|
|
||||||
"outpost_channel": self.channel_name,
|
|
||||||
"client_channel": self.dest_channel_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def receive(self, text_data=None, bytes_data=None):
|
|
||||||
"""Mirror data received from guacd running in the outpost
|
|
||||||
to the dest_channel_id which is the channel talking to the browser"""
|
|
||||||
try:
|
|
||||||
await self.channel_layer.send(
|
|
||||||
self.dest_channel_id,
|
|
||||||
{
|
|
||||||
"type": "event.send",
|
|
||||||
"text_data": text_data,
|
|
||||||
"bytes_data": bytes_data,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except ChannelFull:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def event_send(self, event: dict):
|
|
||||||
"""Handler called by client websocket that sends data to this specific
|
|
||||||
outpost connection"""
|
|
||||||
await self.send(text_data=event.get("text_data"), bytes_data=event.get("bytes_data"))
|
|
||||||
|
|
||||||
async def event_disconnect(self, event: dict):
|
|
||||||
"""Tell outpost we're about to disconnect"""
|
|
||||||
await self.send(text_data="0.authentik.disconnect")
|
|
||||||
await self.close()
|
|
|
@ -1,11 +0,0 @@
|
||||||
"""RAC Provider Docker Controller"""
|
|
||||||
from authentik.outposts.controllers.docker import DockerController
|
|
||||||
from authentik.outposts.models import DockerServiceConnection, Outpost
|
|
||||||
|
|
||||||
|
|
||||||
class RACDockerController(DockerController):
|
|
||||||
"""RAC Provider Docker Controller"""
|
|
||||||
|
|
||||||
def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
|
|
||||||
super().__init__(outpost, connection)
|
|
||||||
self.deployment_ports = []
|
|
|
@ -1,13 +0,0 @@
|
||||||
"""RAC Provider Kubernetes Controller"""
|
|
||||||
from authentik.outposts.controllers.k8s.service import ServiceReconciler
|
|
||||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
|
||||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
|
|
||||||
|
|
||||||
|
|
||||||
class RACKubernetesController(KubernetesController):
|
|
||||||
"""RAC Provider Kubernetes Controller"""
|
|
||||||
|
|
||||||
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
|
|
||||||
super().__init__(outpost, connection)
|
|
||||||
self.deployment_ports = []
|
|
||||||
del self.reconcilers[ServiceReconciler.reconciler_name()]
|
|
|
@ -1,164 +0,0 @@
|
||||||
# Generated by Django 4.2.8 on 2023-12-29 15:58
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
import authentik.core.models
|
|
||||||
import authentik.lib.utils.time
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_policies", "0011_policybinding_failure_result_and_more"),
|
|
||||||
("authentik_core", "0032_group_roles"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="RACPropertyMapping",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"propertymapping_ptr",
|
|
||||||
models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="authentik_core.propertymapping",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("static_settings", models.JSONField(default=dict)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "RAC Property Mapping",
|
|
||||||
"verbose_name_plural": "RAC Property Mappings",
|
|
||||||
},
|
|
||||||
bases=("authentik_core.propertymapping",),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="RACProvider",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"provider_ptr",
|
|
||||||
models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="authentik_core.provider",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("settings", models.JSONField(default=dict)),
|
|
||||||
(
|
|
||||||
"auth_mode",
|
|
||||||
models.TextField(
|
|
||||||
choices=[("static", "Static"), ("prompt", "Prompt")], default="prompt"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"connection_expiry",
|
|
||||||
models.TextField(
|
|
||||||
default="hours=8",
|
|
||||||
help_text="Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)",
|
|
||||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "RAC Provider",
|
|
||||||
"verbose_name_plural": "RAC Providers",
|
|
||||||
},
|
|
||||||
bases=("authentik_core.provider",),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Endpoint",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"policybindingmodel_ptr",
|
|
||||||
models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="authentik_policies.policybindingmodel",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.TextField()),
|
|
||||||
("host", models.TextField()),
|
|
||||||
(
|
|
||||||
"protocol",
|
|
||||||
models.TextField(choices=[("rdp", "Rdp"), ("vnc", "Vnc"), ("ssh", "Ssh")]),
|
|
||||||
),
|
|
||||||
("settings", models.JSONField(default=dict)),
|
|
||||||
(
|
|
||||||
"auth_mode",
|
|
||||||
models.TextField(choices=[("static", "Static"), ("prompt", "Prompt")]),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"property_mappings",
|
|
||||||
models.ManyToManyField(
|
|
||||||
blank=True, default=None, to="authentik_core.propertymapping"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"provider",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="authentik_providers_rac.racprovider",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "RAC Endpoint",
|
|
||||||
"verbose_name_plural": "RAC Endpoints",
|
|
||||||
},
|
|
||||||
bases=("authentik_policies.policybindingmodel", models.Model),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ConnectionToken",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"expires",
|
|
||||||
models.DateTimeField(default=authentik.core.models.default_token_duration),
|
|
||||||
),
|
|
||||||
("expiring", models.BooleanField(default=True)),
|
|
||||||
(
|
|
||||||
"connection_token_uuid",
|
|
||||||
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
("token", models.TextField(default=authentik.core.models.default_token_key)),
|
|
||||||
("settings", models.JSONField(default=dict)),
|
|
||||||
(
|
|
||||||
"endpoint",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="authentik_providers_rac.endpoint",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"provider",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="authentik_providers_rac.racprovider",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"session",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="authentik_core.authenticatedsession",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,17 +0,0 @@
|
||||||
# Generated by Django 5.0 on 2024-01-03 23:44
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("authentik_providers_rac", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="endpoint",
|
|
||||||
name="maximum_connections",
|
|
||||||
field=models.IntegerField(default=1),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,192 +0,0 @@
|
||||||
"""RAC Models"""
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from deepmerge import always_merger
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from rest_framework.serializers import Serializer
|
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
|
||||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, default_token_key
|
|
||||||
from authentik.events.models import Event, EventAction
|
|
||||||
from authentik.lib.models import SerializerModel
|
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
|
||||||
from authentik.policies.models import PolicyBindingModel
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class Protocols(models.TextChoices):
|
|
||||||
"""Supported protocols"""
|
|
||||||
|
|
||||||
RDP = "rdp"
|
|
||||||
VNC = "vnc"
|
|
||||||
SSH = "ssh"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationMode(models.TextChoices):
|
|
||||||
"""Authentication modes"""
|
|
||||||
|
|
||||||
STATIC = "static"
|
|
||||||
PROMPT = "prompt"
|
|
||||||
|
|
||||||
|
|
||||||
class RACProvider(Provider):
|
|
||||||
"""Remotely access computers/servers via RDP/SSH/VNC."""
|
|
||||||
|
|
||||||
settings = models.JSONField(default=dict)
|
|
||||||
auth_mode = models.TextField(
|
|
||||||
choices=AuthenticationMode.choices, default=AuthenticationMode.PROMPT
|
|
||||||
)
|
|
||||||
connection_expiry = models.TextField(
|
|
||||||
default="hours=8",
|
|
||||||
validators=[timedelta_string_validator],
|
|
||||||
help_text=_(
|
|
||||||
"Determines how long a session lasts. Default of 0 means "
|
|
||||||
"that the sessions lasts until the browser is closed. "
|
|
||||||
"(Format: hours=-1;minutes=-2;seconds=-3)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def launch_url(self) -> Optional[str]:
|
|
||||||
"""URL to this provider and initiate authorization for the user.
|
|
||||||
Can return None for providers that are not URL-based"""
|
|
||||||
return "goauthentik.io://providers/rac/launch"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def component(self) -> str:
|
|
||||||
return "ak-provider-rac-form"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer(self) -> type[Serializer]:
|
|
||||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
|
|
||||||
|
|
||||||
return RACProviderSerializer
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("RAC Provider")
|
|
||||||
verbose_name_plural = _("RAC Providers")
|
|
||||||
|
|
||||||
|
|
||||||
class Endpoint(SerializerModel, PolicyBindingModel):
|
|
||||||
"""Remote-accessible endpoint"""
|
|
||||||
|
|
||||||
name = models.TextField()
|
|
||||||
host = models.TextField()
|
|
||||||
protocol = models.TextField(choices=Protocols.choices)
|
|
||||||
settings = models.JSONField(default=dict)
|
|
||||||
auth_mode = models.TextField(choices=AuthenticationMode.choices)
|
|
||||||
provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE)
|
|
||||||
maximum_connections = models.IntegerField(default=1)
|
|
||||||
|
|
||||||
property_mappings = models.ManyToManyField(
|
|
||||||
"authentik_core.PropertyMapping", default=None, blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer(self) -> type[Serializer]:
|
|
||||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
|
|
||||||
|
|
||||||
return EndpointSerializer
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"RAC Endpoint {self.name}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("RAC Endpoint")
|
|
||||||
verbose_name_plural = _("RAC Endpoints")
|
|
||||||
|
|
||||||
|
|
||||||
class RACPropertyMapping(PropertyMapping):
|
|
||||||
"""Configure settings for remote access endpoints."""
|
|
||||||
|
|
||||||
static_settings = models.JSONField(default=dict)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def component(self) -> str:
|
|
||||||
return "ak-property-mapping-rac-form"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer(self) -> type[Serializer]:
|
|
||||||
from authentik.enterprise.providers.rac.api.property_mappings import (
|
|
||||||
RACPropertyMappingSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
return RACPropertyMappingSerializer
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("RAC Property Mapping")
|
|
||||||
verbose_name_plural = _("RAC Property Mappings")
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionToken(ExpiringModel):
|
|
||||||
"""Token for a single connection to a specified endpoint"""
|
|
||||||
|
|
||||||
connection_token_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
|
||||||
provider = models.ForeignKey(RACProvider, on_delete=models.CASCADE)
|
|
||||||
endpoint = models.ForeignKey(Endpoint, on_delete=models.CASCADE)
|
|
||||||
token = models.TextField(default=default_token_key)
|
|
||||||
settings = models.JSONField(default=dict)
|
|
||||||
session = models.ForeignKey("authentik_core.AuthenticatedSession", on_delete=models.CASCADE)
|
|
||||||
|
|
||||||
def get_settings(self) -> dict:
|
|
||||||
"""Get settings"""
|
|
||||||
default_settings = {}
|
|
||||||
if ":" in self.endpoint.host:
|
|
||||||
host, _, port = self.endpoint.host.partition(":")
|
|
||||||
default_settings["hostname"] = host
|
|
||||||
default_settings["port"] = str(port)
|
|
||||||
else:
|
|
||||||
default_settings["hostname"] = self.endpoint.host
|
|
||||||
default_settings["client-name"] = "authentik"
|
|
||||||
# default_settings["enable-drive"] = "true"
|
|
||||||
# default_settings["drive-name"] = "authentik"
|
|
||||||
settings = {}
|
|
||||||
always_merger.merge(settings, default_settings)
|
|
||||||
always_merger.merge(settings, self.endpoint.provider.settings)
|
|
||||||
always_merger.merge(settings, self.endpoint.settings)
|
|
||||||
always_merger.merge(settings, self.settings)
|
|
||||||
|
|
||||||
def mapping_evaluator(mappings: QuerySet):
|
|
||||||
for mapping in mappings:
|
|
||||||
mapping: RACPropertyMapping
|
|
||||||
if len(mapping.static_settings) > 0:
|
|
||||||
always_merger.merge(settings, mapping.static_settings)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
mapping_settings = mapping.evaluate(
|
|
||||||
self.session.user, None, endpoint=self.endpoint, provider=self.provider
|
|
||||||
)
|
|
||||||
always_merger.merge(settings, mapping_settings)
|
|
||||||
except PropertyMappingExpressionException as exc:
|
|
||||||
Event.new(
|
|
||||||
EventAction.CONFIGURATION_ERROR,
|
|
||||||
message=f"Failed to evaluate property-mapping: '{mapping.name}'",
|
|
||||||
provider=self.provider,
|
|
||||||
mapping=mapping,
|
|
||||||
).set_user(self.session.user).save()
|
|
||||||
LOGGER.warning("Failed to evaluate property mapping", exc=exc)
|
|
||||||
|
|
||||||
mapping_evaluator(
|
|
||||||
RACPropertyMapping.objects.filter(provider__in=[self.provider]).order_by("name")
|
|
||||||
)
|
|
||||||
mapping_evaluator(
|
|
||||||
RACPropertyMapping.objects.filter(endpoint__in=[self.endpoint]).order_by("name")
|
|
||||||
)
|
|
||||||
|
|
||||||
settings["drive-path"] = f"/tmp/connection/{self.token}" # nosec
|
|
||||||
settings["create-drive-path"] = "true"
|
|
||||||
# Ensure all values of the settings dict are strings
|
|
||||||
for key, value in settings.items():
|
|
||||||
if isinstance(value, str):
|
|
||||||
continue
|
|
||||||
# Special case for bools
|
|
||||||
if isinstance(value, bool):
|
|
||||||
settings[key] = str(value).lower()
|
|
||||||
continue
|
|
||||||
settings[key] = str(value)
|
|
||||||
return settings
|
|
|
@ -1,54 +0,0 @@
|
||||||
"""RAC Signals"""
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from channels.layers import get_channel_layer
|
|
||||||
from django.contrib.auth.signals import user_logged_out
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.db.models import Model
|
|
||||||
from django.db.models.signals import post_save, pre_delete
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from django.http import HttpRequest
|
|
||||||
|
|
||||||
from authentik.core.models import User
|
|
||||||
from authentik.enterprise.providers.rac.api.endpoints import user_endpoint_cache_key
|
|
||||||
from authentik.enterprise.providers.rac.consumer_client import (
|
|
||||||
RAC_CLIENT_GROUP_SESSION,
|
|
||||||
RAC_CLIENT_GROUP_TOKEN,
|
|
||||||
)
|
|
||||||
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
|
||||||
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
|
|
||||||
"""Disconnect any open RAC connections"""
|
|
||||||
layer = get_channel_layer()
|
|
||||||
async_to_sync(layer.group_send)(
|
|
||||||
RAC_CLIENT_GROUP_SESSION
|
|
||||||
% {
|
|
||||||
"session": request.session.session_key,
|
|
||||||
},
|
|
||||||
{"type": "event.disconnect", "reason": "session_logout"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=ConnectionToken)
|
|
||||||
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
|
|
||||||
"""Disconnect session when connection token is deleted"""
|
|
||||||
layer = get_channel_layer()
|
|
||||||
async_to_sync(layer.group_send)(
|
|
||||||
RAC_CLIENT_GROUP_TOKEN
|
|
||||||
% {
|
|
||||||
"token": instance.token,
|
|
||||||
},
|
|
||||||
{"type": "event.disconnect", "reason": "token_delete"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Endpoint)
|
|
||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
|
||||||
"""Clear user's application cache upon application creation"""
|
|
||||||
if not created: # pragma: no cover
|
|
||||||
return
|
|
||||||
|
|
||||||
# Delete user endpoint cache
|
|
||||||
keys = cache.keys(user_endpoint_cache_key("*"))
|
|
||||||
cache.delete_many(keys)
|
|
|
@ -1,18 +0,0 @@
|
||||||
{% extends "base/skeleton.html" %}
|
|
||||||
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<script src="{% static 'dist/enterprise/rac/index.js' %}?version={{ version }}" type="module"></script>
|
|
||||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
|
||||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
||||||
<link rel="icon" href="{{ tenant.branding_favicon }}">
|
|
||||||
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
|
|
||||||
{% include "base/header_js.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<ak-rac token="{{ url_kwargs.token }}" endpointName="{{ token.endpoint.name }}">
|
|
||||||
<ak-loading></ak-loading>
|
|
||||||
</ak-rac>
|
|
||||||
{% endblock %}
|
|
|
@ -1,171 +0,0 @@
|
||||||
"""Test Endpoints API"""
|
|
||||||
|
|
||||||
from django.urls import reverse
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from authentik.core.models import Application
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
|
|
||||||
from authentik.lib.generators import generate_id
|
|
||||||
from authentik.policies.dummy.models import DummyPolicy
|
|
||||||
from authentik.policies.models import PolicyBinding
|
|
||||||
|
|
||||||
|
|
||||||
class TestEndpointsAPI(APITestCase):
|
|
||||||
"""Test endpoints API"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.user = create_test_admin_user()
|
|
||||||
self.provider = RACProvider.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
)
|
|
||||||
self.app = Application.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
slug=generate_id(),
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
self.allowed = Endpoint.objects.create(
|
|
||||||
name=f"a-{generate_id()}",
|
|
||||||
host=generate_id(),
|
|
||||||
protocol=Protocols.RDP,
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
self.denied = Endpoint.objects.create(
|
|
||||||
name=f"b-{generate_id()}",
|
|
||||||
host=generate_id(),
|
|
||||||
protocol=Protocols.RDP,
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
PolicyBinding.objects.create(
|
|
||||||
target=self.denied,
|
|
||||||
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_list(self):
|
|
||||||
"""Test list operation without superuser_full_list"""
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("authentik_api:endpoint-list"))
|
|
||||||
self.assertJSONEqual(
|
|
||||||
response.content.decode(),
|
|
||||||
{
|
|
||||||
"pagination": {
|
|
||||||
"next": 0,
|
|
||||||
"previous": 0,
|
|
||||||
"count": 2,
|
|
||||||
"current": 1,
|
|
||||||
"total_pages": 1,
|
|
||||||
"start_index": 1,
|
|
||||||
"end_index": 2,
|
|
||||||
},
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"pk": str(self.allowed.pk),
|
|
||||||
"name": self.allowed.name,
|
|
||||||
"provider": self.provider.pk,
|
|
||||||
"provider_obj": {
|
|
||||||
"pk": self.provider.pk,
|
|
||||||
"name": self.provider.name,
|
|
||||||
"authentication_flow": None,
|
|
||||||
"authorization_flow": None,
|
|
||||||
"property_mappings": [],
|
|
||||||
"connection_expiry": "hours=8",
|
|
||||||
"component": "ak-provider-rac-form",
|
|
||||||
"assigned_application_slug": self.app.slug,
|
|
||||||
"assigned_application_name": self.app.name,
|
|
||||||
"verbose_name": "RAC Provider",
|
|
||||||
"verbose_name_plural": "RAC Providers",
|
|
||||||
"meta_model_name": "authentik_providers_rac.racprovider",
|
|
||||||
"settings": {},
|
|
||||||
"outpost_set": [],
|
|
||||||
},
|
|
||||||
"protocol": "rdp",
|
|
||||||
"host": self.allowed.host,
|
|
||||||
"maximum_connections": 1,
|
|
||||||
"settings": {},
|
|
||||||
"property_mappings": [],
|
|
||||||
"auth_mode": "",
|
|
||||||
"launch_url": f"/application/rac/{self.app.slug}/{str(self.allowed.pk)}/",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_list_superuser_full_list(self):
|
|
||||||
"""Test list operation with superuser_full_list"""
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("authentik_api:endpoint-list") + "?superuser_full_list=true"
|
|
||||||
)
|
|
||||||
self.assertJSONEqual(
|
|
||||||
response.content.decode(),
|
|
||||||
{
|
|
||||||
"pagination": {
|
|
||||||
"next": 0,
|
|
||||||
"previous": 0,
|
|
||||||
"count": 2,
|
|
||||||
"current": 1,
|
|
||||||
"total_pages": 1,
|
|
||||||
"start_index": 1,
|
|
||||||
"end_index": 2,
|
|
||||||
},
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"pk": str(self.allowed.pk),
|
|
||||||
"name": self.allowed.name,
|
|
||||||
"provider": self.provider.pk,
|
|
||||||
"provider_obj": {
|
|
||||||
"pk": self.provider.pk,
|
|
||||||
"name": self.provider.name,
|
|
||||||
"authentication_flow": None,
|
|
||||||
"authorization_flow": None,
|
|
||||||
"property_mappings": [],
|
|
||||||
"component": "ak-provider-rac-form",
|
|
||||||
"assigned_application_slug": self.app.slug,
|
|
||||||
"assigned_application_name": self.app.name,
|
|
||||||
"connection_expiry": "hours=8",
|
|
||||||
"verbose_name": "RAC Provider",
|
|
||||||
"verbose_name_plural": "RAC Providers",
|
|
||||||
"meta_model_name": "authentik_providers_rac.racprovider",
|
|
||||||
"settings": {},
|
|
||||||
"outpost_set": [],
|
|
||||||
},
|
|
||||||
"protocol": "rdp",
|
|
||||||
"host": self.allowed.host,
|
|
||||||
"maximum_connections": 1,
|
|
||||||
"settings": {},
|
|
||||||
"property_mappings": [],
|
|
||||||
"auth_mode": "",
|
|
||||||
"launch_url": f"/application/rac/{self.app.slug}/{str(self.allowed.pk)}/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"pk": str(self.denied.pk),
|
|
||||||
"name": self.denied.name,
|
|
||||||
"provider": self.provider.pk,
|
|
||||||
"provider_obj": {
|
|
||||||
"pk": self.provider.pk,
|
|
||||||
"name": self.provider.name,
|
|
||||||
"authentication_flow": None,
|
|
||||||
"authorization_flow": None,
|
|
||||||
"property_mappings": [],
|
|
||||||
"component": "ak-provider-rac-form",
|
|
||||||
"assigned_application_slug": self.app.slug,
|
|
||||||
"assigned_application_name": self.app.name,
|
|
||||||
"connection_expiry": "hours=8",
|
|
||||||
"verbose_name": "RAC Provider",
|
|
||||||
"verbose_name_plural": "RAC Providers",
|
|
||||||
"meta_model_name": "authentik_providers_rac.racprovider",
|
|
||||||
"settings": {},
|
|
||||||
"outpost_set": [],
|
|
||||||
},
|
|
||||||
"protocol": "rdp",
|
|
||||||
"host": self.denied.host,
|
|
||||||
"maximum_connections": 1,
|
|
||||||
"settings": {},
|
|
||||||
"property_mappings": [],
|
|
||||||
"auth_mode": "",
|
|
||||||
"launch_url": f"/application/rac/{self.app.slug}/{str(self.denied.pk)}/",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
|
@ -1,144 +0,0 @@
|
||||||
"""Test RAC Models"""
|
|
||||||
from django.test import TransactionTestCase
|
|
||||||
|
|
||||||
from authentik.core.models import Application, AuthenticatedSession
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
from authentik.enterprise.providers.rac.models import (
|
|
||||||
ConnectionToken,
|
|
||||||
Endpoint,
|
|
||||||
Protocols,
|
|
||||||
RACPropertyMapping,
|
|
||||||
RACProvider,
|
|
||||||
)
|
|
||||||
from authentik.lib.generators import generate_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestModels(TransactionTestCase):
|
|
||||||
"""Test RAC Models"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.user = create_test_admin_user()
|
|
||||||
self.provider = RACProvider.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
)
|
|
||||||
self.app = Application.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
slug=generate_id(),
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
self.endpoint = Endpoint.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
host=f"{generate_id()}:1324",
|
|
||||||
protocol=Protocols.RDP,
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_settings_merge(self):
|
|
||||||
"""Test settings merge"""
|
|
||||||
token = ConnectionToken.objects.create(
|
|
||||||
provider=self.provider,
|
|
||||||
endpoint=self.endpoint,
|
|
||||||
session=AuthenticatedSession.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
session_key=generate_id(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
path = f"/tmp/connection/{token.token}" # nosec
|
|
||||||
self.assertEqual(
|
|
||||||
token.get_settings(),
|
|
||||||
{
|
|
||||||
"hostname": self.endpoint.host.split(":")[0],
|
|
||||||
"port": "1324",
|
|
||||||
"client-name": "authentik",
|
|
||||||
"drive-path": path,
|
|
||||||
"create-drive-path": "true",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Set settings in provider
|
|
||||||
self.provider.settings = {"level": "provider"}
|
|
||||||
self.provider.save()
|
|
||||||
self.assertEqual(
|
|
||||||
token.get_settings(),
|
|
||||||
{
|
|
||||||
"hostname": self.endpoint.host.split(":")[0],
|
|
||||||
"port": "1324",
|
|
||||||
"client-name": "authentik",
|
|
||||||
"drive-path": path,
|
|
||||||
"create-drive-path": "true",
|
|
||||||
"level": "provider",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Set settings in endpoint
|
|
||||||
self.endpoint.settings = {
|
|
||||||
"level": "endpoint",
|
|
||||||
}
|
|
||||||
self.endpoint.save()
|
|
||||||
self.assertEqual(
|
|
||||||
token.get_settings(),
|
|
||||||
{
|
|
||||||
"hostname": self.endpoint.host.split(":")[0],
|
|
||||||
"port": "1324",
|
|
||||||
"client-name": "authentik",
|
|
||||||
"drive-path": path,
|
|
||||||
"create-drive-path": "true",
|
|
||||||
"level": "endpoint",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Set settings in token
|
|
||||||
token.settings = {
|
|
||||||
"level": "token",
|
|
||||||
}
|
|
||||||
token.save()
|
|
||||||
self.assertEqual(
|
|
||||||
token.get_settings(),
|
|
||||||
{
|
|
||||||
"hostname": self.endpoint.host.split(":")[0],
|
|
||||||
"port": "1324",
|
|
||||||
"client-name": "authentik",
|
|
||||||
"drive-path": path,
|
|
||||||
"create-drive-path": "true",
|
|
||||||
"level": "token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Set settings in property mapping (provider)
|
|
||||||
mapping = RACPropertyMapping.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
expression="""return {
|
|
||||||
"level": "property_mapping_provider"
|
|
||||||
}""",
|
|
||||||
)
|
|
||||||
self.provider.property_mappings.add(mapping)
|
|
||||||
self.assertEqual(
|
|
||||||
token.get_settings(),
|
|
||||||
{
|
|
||||||
"hostname": self.endpoint.host.split(":")[0],
|
|
||||||
"port": "1324",
|
|
||||||
"client-name": "authentik",
|
|
||||||
"drive-path": path,
|
|
||||||
"create-drive-path": "true",
|
|
||||||
"level": "property_mapping_provider",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Set settings in property mapping (endpoint)
|
|
||||||
mapping = RACPropertyMapping.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
static_settings={
|
|
||||||
"level": "property_mapping_endpoint",
|
|
||||||
"foo": True,
|
|
||||||
"bar": 6,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.endpoint.property_mappings.add(mapping)
|
|
||||||
self.assertEqual(
|
|
||||||
token.get_settings(),
|
|
||||||
{
|
|
||||||
"hostname": self.endpoint.host.split(":")[0],
|
|
||||||
"port": "1324",
|
|
||||||
"client-name": "authentik",
|
|
||||||
"drive-path": path,
|
|
||||||
"create-drive-path": "true",
|
|
||||||
"level": "property_mapping_endpoint",
|
|
||||||
"foo": "true",
|
|
||||||
"bar": "6",
|
|
||||||
},
|
|
||||||
)
|
|
|
@ -1,132 +0,0 @@
|
||||||
"""RAC Views tests"""
|
|
||||||
from datetime import timedelta
|
|
||||||
from json import loads
|
|
||||||
from time import mktime
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from authentik.core.models import Application
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
|
||||||
from authentik.enterprise.models import License, LicenseKey
|
|
||||||
from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
|
|
||||||
from authentik.lib.generators import generate_id
|
|
||||||
from authentik.policies.denied import AccessDeniedResponse
|
|
||||||
from authentik.policies.dummy.models import DummyPolicy
|
|
||||||
from authentik.policies.models import PolicyBinding
|
|
||||||
|
|
||||||
|
|
||||||
class TestRACViews(APITestCase):
|
|
||||||
"""RAC Views tests"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.user = create_test_admin_user()
|
|
||||||
self.flow = create_test_flow()
|
|
||||||
self.provider = RACProvider.objects.create(name=generate_id(), authorization_flow=self.flow)
|
|
||||||
self.app = Application.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
slug=generate_id(),
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
self.endpoint = Endpoint.objects.create(
|
|
||||||
name=generate_id(),
|
|
||||||
host=f"{generate_id()}:1324",
|
|
||||||
protocol=Protocols.RDP,
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch(
|
|
||||||
"authentik.enterprise.models.LicenseKey.validate",
|
|
||||||
MagicMock(
|
|
||||||
return_value=LicenseKey(
|
|
||||||
aud="",
|
|
||||||
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
|
|
||||||
name=generate_id(),
|
|
||||||
internal_users=100,
|
|
||||||
external_users=100,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_no_policy(self):
|
|
||||||
"""Test request"""
|
|
||||||
License.objects.create(key=generate_id())
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse(
|
|
||||||
"authentik_providers_rac:start",
|
|
||||||
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
flow_response = self.client.get(
|
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
|
||||||
)
|
|
||||||
body = loads(flow_response.content)
|
|
||||||
next_url = body["to"]
|
|
||||||
final_response = self.client.get(next_url)
|
|
||||||
self.assertEqual(final_response.status_code, 200)
|
|
||||||
|
|
||||||
@patch(
|
|
||||||
"authentik.enterprise.models.LicenseKey.validate",
|
|
||||||
MagicMock(
|
|
||||||
return_value=LicenseKey(
|
|
||||||
aud="",
|
|
||||||
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
|
|
||||||
name=generate_id(),
|
|
||||||
internal_users=100,
|
|
||||||
external_users=100,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_app_deny(self):
|
|
||||||
"""Test request (deny on app level)"""
|
|
||||||
PolicyBinding.objects.create(
|
|
||||||
target=self.app,
|
|
||||||
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
License.objects.create(key=generate_id())
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse(
|
|
||||||
"authentik_providers_rac:start",
|
|
||||||
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertIsInstance(response, AccessDeniedResponse)
|
|
||||||
|
|
||||||
@patch(
|
|
||||||
"authentik.enterprise.models.LicenseKey.validate",
|
|
||||||
MagicMock(
|
|
||||||
return_value=LicenseKey(
|
|
||||||
aud="",
|
|
||||||
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
|
|
||||||
name=generate_id(),
|
|
||||||
internal_users=100,
|
|
||||||
external_users=100,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_endpoint_deny(self):
|
|
||||||
"""Test request (deny on endpoint level)"""
|
|
||||||
PolicyBinding.objects.create(
|
|
||||||
target=self.endpoint,
|
|
||||||
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
License.objects.create(key=generate_id())
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse(
|
|
||||||
"authentik_providers_rac:start",
|
|
||||||
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
flow_response = self.client.get(
|
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
|
||||||
)
|
|
||||||
body = loads(flow_response.content)
|
|
||||||
self.assertEqual(body["component"], "ak-stage-access-denied")
|
|
|
@ -1,47 +0,0 @@
|
||||||
"""rac urls"""
|
|
||||||
from channels.auth import AuthMiddleware
|
|
||||||
from channels.sessions import CookieMiddleware
|
|
||||||
from django.urls import path
|
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
|
||||||
|
|
||||||
from authentik.core.channels import TokenOutpostMiddleware
|
|
||||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet
|
|
||||||
from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet
|
|
||||||
from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet
|
|
||||||
from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer
|
|
||||||
from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer
|
|
||||||
from authentik.enterprise.providers.rac.views import RACInterface, RACStartView
|
|
||||||
from authentik.root.asgi_middleware import SessionMiddleware
|
|
||||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path(
|
|
||||||
"application/rac/<slug:app>/<uuid:endpoint>/",
|
|
||||||
ensure_csrf_cookie(RACStartView.as_view()),
|
|
||||||
name="start",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"if/rac/<str:token>/",
|
|
||||||
ensure_csrf_cookie(RACInterface.as_view()),
|
|
||||||
name="if-rac",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
|
||||||
path(
|
|
||||||
"ws/rac/<str:token>/",
|
|
||||||
ChannelsLoggingMiddleware(
|
|
||||||
CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi())))
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"ws/outpost_rac/<str:channel>/",
|
|
||||||
ChannelsLoggingMiddleware(TokenOutpostMiddleware(RACOutpostConsumer.as_asgi())),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
api_urlpatterns = [
|
|
||||||
("providers/rac", RACProviderViewSet),
|
|
||||||
("propertymappings/rac", RACPropertyMappingViewSet),
|
|
||||||
("rac/endpoints", EndpointViewSet),
|
|
||||||
]
|
|
|
@ -1,145 +0,0 @@
|
||||||
"""RAC Views"""
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.http import Http404, HttpRequest, HttpResponse
|
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from authentik.core.models import Application, AuthenticatedSession
|
|
||||||
from authentik.core.views.interface import InterfaceView
|
|
||||||
from authentik.enterprise.policy import EnterprisePolicyAccessView
|
|
||||||
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
|
||||||
from authentik.events.models import Event, EventAction
|
|
||||||
from authentik.flows.challenge import RedirectChallenge
|
|
||||||
from authentik.flows.exceptions import FlowNonApplicableException
|
|
||||||
from authentik.flows.models import in_memory_stage
|
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
|
||||||
from authentik.flows.stage import RedirectStage
|
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
|
||||||
from authentik.lib.utils.urls import redirect_with_qs
|
|
||||||
from authentik.policies.engine import PolicyEngine
|
|
||||||
|
|
||||||
|
|
||||||
class RACStartView(EnterprisePolicyAccessView):
|
|
||||||
"""Start a RAC connection by checking access and creating a connection token"""
|
|
||||||
|
|
||||||
endpoint: Endpoint
|
|
||||||
|
|
||||||
def resolve_provider_application(self):
|
|
||||||
self.application = get_object_or_404(Application, slug=self.kwargs["app"])
|
|
||||||
# Endpoint permissions are validated in the RACFinalStage below
|
|
||||||
self.endpoint = get_object_or_404(Endpoint, pk=self.kwargs["endpoint"])
|
|
||||||
self.provider = RACProvider.objects.get(application=self.application)
|
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
|
||||||
"""Start flow planner for RAC provider"""
|
|
||||||
planner = FlowPlanner(self.provider.authorization_flow)
|
|
||||||
planner.allow_empty_flows = True
|
|
||||||
try:
|
|
||||||
plan = planner.plan(
|
|
||||||
self.request,
|
|
||||||
{
|
|
||||||
PLAN_CONTEXT_APPLICATION: self.application,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except FlowNonApplicableException:
|
|
||||||
raise Http404
|
|
||||||
plan.insert_stage(
|
|
||||||
in_memory_stage(
|
|
||||||
RACFinalStage,
|
|
||||||
application=self.application,
|
|
||||||
endpoint=self.endpoint,
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
request.session[SESSION_KEY_PLAN] = plan
|
|
||||||
return redirect_with_qs(
|
|
||||||
"authentik_core:if-flow",
|
|
||||||
request.GET,
|
|
||||||
flow_slug=self.provider.authorization_flow.slug,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RACInterface(InterfaceView):
|
|
||||||
"""Start RAC connection"""
|
|
||||||
|
|
||||||
template_name = "if/rac.html"
|
|
||||||
token: ConnectionToken
|
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
||||||
# Early sanity check to ensure token still exists
|
|
||||||
token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first()
|
|
||||||
if not token:
|
|
||||||
return redirect("authentik_core:if-user")
|
|
||||||
self.token = token
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
kwargs["token"] = self.token
|
|
||||||
return super().get_context_data(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class RACFinalStage(RedirectStage):
|
|
||||||
"""RAC Connection final stage, set the connection token in the stage"""
|
|
||||||
|
|
||||||
endpoint: Endpoint
|
|
||||||
provider: RACProvider
|
|
||||||
application: Application
|
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
||||||
self.endpoint = self.executor.current_stage.endpoint
|
|
||||||
self.provider = self.executor.current_stage.provider
|
|
||||||
self.application = self.executor.current_stage.application
|
|
||||||
# Check policies bound to endpoint directly
|
|
||||||
engine = PolicyEngine(self.endpoint, self.request.user, self.request)
|
|
||||||
engine.use_cache = False
|
|
||||||
engine.build()
|
|
||||||
passing = engine.result
|
|
||||||
if not passing.passing:
|
|
||||||
return self.executor.stage_invalid(", ".join(passing.messages))
|
|
||||||
# Check if we're already at the maximum connection limit
|
|
||||||
all_tokens = ConnectionToken.filter_not_expired(
|
|
||||||
endpoint=self.endpoint,
|
|
||||||
).exclude(endpoint__maximum_connections__lte=-1)
|
|
||||||
if all_tokens.count() >= self.endpoint.maximum_connections:
|
|
||||||
msg = [_("Maximum connection limit reached.")]
|
|
||||||
# Check if any other tokens exist for the current user, and inform them
|
|
||||||
# they are already connected
|
|
||||||
if all_tokens.filter(session__user=self.request.user).exists():
|
|
||||||
msg.append(_("(You are already connected in another tab/window)"))
|
|
||||||
return self.executor.stage_invalid(" ".join(msg))
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
|
||||||
token = ConnectionToken.objects.create(
|
|
||||||
provider=self.provider,
|
|
||||||
endpoint=self.endpoint,
|
|
||||||
settings=self.executor.plan.context.get("connection_settings", {}),
|
|
||||||
session=AuthenticatedSession.objects.filter(
|
|
||||||
session_key=self.request.session.session_key
|
|
||||||
).first(),
|
|
||||||
expires=now() + timedelta_from_string(self.provider.connection_expiry),
|
|
||||||
expiring=True,
|
|
||||||
)
|
|
||||||
Event.new(
|
|
||||||
EventAction.AUTHORIZE_APPLICATION,
|
|
||||||
authorized_application=self.application,
|
|
||||||
flow=self.executor.plan.flow_pk,
|
|
||||||
endpoint=self.endpoint.name,
|
|
||||||
).from_http(self.request)
|
|
||||||
setattr(
|
|
||||||
self.executor.current_stage,
|
|
||||||
"destination",
|
|
||||||
self.request.build_absolute_uri(
|
|
||||||
reverse(
|
|
||||||
"authentik_providers_rac:if-rac",
|
|
||||||
kwargs={
|
|
||||||
"token": str(token.token),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return super().get_challenge(*args, **kwargs)
|
|
|
@ -10,7 +10,3 @@ CELERY_BEAT_SCHEDULE = {
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TENANT_APPS = [
|
|
||||||
"authentik.enterprise.providers.rac",
|
|
||||||
]
|
|
||||||
|
|
|
@ -5,8 +5,7 @@ from json import loads
|
||||||
import django_filters
|
import django_filters
|
||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
from django.db.models.fields.json import KeyTextTransform, KeyTransform
|
from django.db.models.fields.json import KeyTextTransform, KeyTransform
|
||||||
from django.db.models.functions import ExtractDay, ExtractHour
|
from django.db.models.functions import ExtractDay
|
||||||
from django.db.models.query_utils import Q
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
|
@ -36,7 +35,7 @@ class EventSerializer(ModelSerializer):
|
||||||
"client_ip",
|
"client_ip",
|
||||||
"created",
|
"created",
|
||||||
"expires",
|
"expires",
|
||||||
"brand",
|
"tenant",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -77,10 +76,10 @@ class EventsFilter(django_filters.FilterSet):
|
||||||
field_name="action",
|
field_name="action",
|
||||||
lookup_expr="icontains",
|
lookup_expr="icontains",
|
||||||
)
|
)
|
||||||
brand_name = django_filters.CharFilter(
|
tenant_name = django_filters.CharFilter(
|
||||||
field_name="brand",
|
field_name="tenant",
|
||||||
lookup_expr="name",
|
lookup_expr="name",
|
||||||
label="Brand name",
|
label="Tenant name",
|
||||||
)
|
)
|
||||||
|
|
||||||
def filter_context_model_pk(self, queryset, name, value):
|
def filter_context_model_pk(self, queryset, name, value):
|
||||||
|
@ -88,12 +87,7 @@ class EventsFilter(django_filters.FilterSet):
|
||||||
we need to remove the dashes that a client may send. We can't use a
|
we need to remove the dashes that a client may send. We can't use a
|
||||||
UUIDField for this, as some models might not have a UUID PK"""
|
UUIDField for this, as some models might not have a UUID PK"""
|
||||||
value = str(value).replace("-", "")
|
value = str(value).replace("-", "")
|
||||||
query = Q(context__model__pk=value)
|
return queryset.filter(context__model__pk=value)
|
||||||
try:
|
|
||||||
query |= Q(context__model__pk=int(value))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return queryset.filter(query)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
|
@ -155,15 +149,7 @@ class EventViewSet(ModelViewSet):
|
||||||
return Response(EventTopPerUserSerializer(instance=events, many=True).data)
|
return Response(EventTopPerUserSerializer(instance=events, many=True).data)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
responses={200: CoordinateSerializer(many=True)},
|
methods=["GET"],
|
||||||
)
|
|
||||||
@action(detail=False, methods=["GET"], pagination_class=None)
|
|
||||||
def volume(self, request: Request) -> Response:
|
|
||||||
"""Get event volume for specified filters and timeframe"""
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
|
||||||
return Response(queryset.get_events_per(timedelta(days=7), ExtractHour, 7 * 3))
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
responses={200: CoordinateSerializer(many=True)},
|
responses={200: CoordinateSerializer(many=True)},
|
||||||
filters=[],
|
filters=[],
|
||||||
parameters=[
|
parameters=[
|
||||||
|
|
|
@ -2,12 +2,11 @@
|
||||||
from prometheus_client import Gauge
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
from authentik.lib.config import CONFIG, ENV_PREFIX
|
|
||||||
|
|
||||||
GAUGE_TASKS = Gauge(
|
GAUGE_TASKS = Gauge(
|
||||||
"authentik_system_tasks",
|
"authentik_system_tasks",
|
||||||
"System tasks and their status",
|
"System tasks and their status",
|
||||||
["tenant", "task_name", "task_uid", "status"],
|
["task_name", "task_uid", "status"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,27 +18,6 @@ class AuthentikEventsConfig(ManagedAppConfig):
|
||||||
verbose_name = "authentik Events"
|
verbose_name = "authentik Events"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
def reconcile_global_load_events_signals(self):
|
def reconcile_load_events_signals(self):
|
||||||
"""Load events signals"""
|
"""Load events signals"""
|
||||||
self.import_module("authentik.events.signals")
|
self.import_module("authentik.events.signals")
|
||||||
|
|
||||||
def reconcile_global_check_deprecations(self):
|
|
||||||
"""Check for config deprecations"""
|
|
||||||
from authentik.events.models import Event, EventAction
|
|
||||||
|
|
||||||
for key_replace, msg in CONFIG.deprecations.items():
|
|
||||||
key, replace = key_replace
|
|
||||||
key_env = f"{ENV_PREFIX}_{key.replace('.', '__')}".upper()
|
|
||||||
replace_env = f"{ENV_PREFIX}_{replace.replace('.', '__')}".upper()
|
|
||||||
if Event.objects.filter(
|
|
||||||
action=EventAction.CONFIGURATION_ERROR, context__deprecated_option=key
|
|
||||||
).exists():
|
|
||||||
continue
|
|
||||||
Event.new(
|
|
||||||
EventAction.CONFIGURATION_ERROR,
|
|
||||||
deprecated_option=key,
|
|
||||||
deprecated_env=key_env,
|
|
||||||
replacement_option=replace,
|
|
||||||
replacement_env=replace_env,
|
|
||||||
message=msg,
|
|
||||||
).save()
|
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
"""ASN Enricher"""
|
|
||||||
from typing import TYPE_CHECKING, Optional, TypedDict
|
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from geoip2.errors import GeoIP2Error
|
|
||||||
from geoip2.models import ASN
|
|
||||||
from sentry_sdk import Hub
|
|
||||||
|
|
||||||
from authentik.events.context_processors.mmdb import MMDBContextProcessor
|
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.root.middleware import ClientIPMiddleware
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from authentik.api.v3.config import Capabilities
|
|
||||||
from authentik.events.models import Event
|
|
||||||
|
|
||||||
|
|
||||||
class ASNDict(TypedDict):
|
|
||||||
"""ASN Details"""
|
|
||||||
|
|
||||||
asn: int
|
|
||||||
as_org: str | None
|
|
||||||
network: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class ASNContextProcessor(MMDBContextProcessor):
|
|
||||||
"""ASN Database reader wrapper"""
|
|
||||||
|
|
||||||
def capability(self) -> Optional["Capabilities"]:
|
|
||||||
from authentik.api.v3.config import Capabilities
|
|
||||||
|
|
||||||
return Capabilities.CAN_ASN
|
|
||||||
|
|
||||||
def path(self) -> str | None:
|
|
||||||
return CONFIG.get("events.context_processors.asn")
|
|
||||||
|
|
||||||
def enrich_event(self, event: "Event"):
|
|
||||||
asn = self.asn_dict(event.client_ip)
|
|
||||||
if not asn:
|
|
||||||
return
|
|
||||||
event.context["asn"] = asn
|
|
||||||
|
|
||||||
def enrich_context(self, request: HttpRequest) -> dict:
|
|
||||||
return {
|
|
||||||
"asn": self.asn_dict(ClientIPMiddleware.get_client_ip(request)),
|
|
||||||
}
|
|
||||||
|
|
||||||
def asn(self, ip_address: str) -> Optional[ASN]:
|
|
||||||
"""Wrapper for Reader.asn"""
|
|
||||||
with Hub.current.start_span(
|
|
||||||
op="authentik.events.asn.asn",
|
|
||||||
description=ip_address,
|
|
||||||
):
|
|
||||||
if not self.configured():
|
|
||||||
return None
|
|
||||||
self.check_expired()
|
|
||||||
try:
|
|
||||||
return self.reader.asn(ip_address)
|
|
||||||
except (GeoIP2Error, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def asn_to_dict(self, asn: ASN | None) -> ASNDict:
|
|
||||||
"""Convert ASN to dict"""
|
|
||||||
if not asn:
|
|
||||||
return {}
|
|
||||||
asn_dict: ASNDict = {
|
|
||||||
"asn": asn.autonomous_system_number,
|
|
||||||
"as_org": asn.autonomous_system_organization,
|
|
||||||
"network": str(asn.network) if asn.network else None,
|
|
||||||
}
|
|
||||||
return asn_dict
|
|
||||||
|
|
||||||
def asn_dict(self, ip_address: str) -> Optional[ASNDict]:
|
|
||||||
"""Wrapper for self.asn that returns a dict"""
|
|
||||||
asn = self.asn(ip_address)
|
|
||||||
if not asn:
|
|
||||||
return None
|
|
||||||
return self.asn_to_dict(asn)
|
|
||||||
|
|
||||||
|
|
||||||
ASN_CONTEXT_PROCESSOR = ASNContextProcessor()
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue