mirror of
https://github.com/goauthentik/authentik
synced 2026-05-07 07:32:23 +02:00
Compare commits
1 Commits
flake
...
flows/corr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db6104bef |
7
.github/actions/setup/action.yml
vendored
7
.github/actions/setup/action.yml
vendored
@@ -12,14 +12,13 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install apt deps & cleanup
|
||||
- name: Install apt deps
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get remove --purge man-db
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v5
|
||||
@@ -51,13 +50,13 @@ runs:
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
||||
with:
|
||||
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
||||
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
||||
- name: Setup dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
shell: bash
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/compose.yml up -d
|
||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||
cd web && npm i
|
||||
- name: Generate config
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
|
||||
@@ -11,6 +11,11 @@ services:
|
||||
ports:
|
||||
- 5432:5432
|
||||
restart: always
|
||||
redis:
|
||||
image: docker.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: always
|
||||
s3:
|
||||
container_name: s3
|
||||
image: docker.io/zenko/cloudserver
|
||||
2
.github/actions/test-results/action.yml
vendored
2
.github/actions/test-results/action.yml
vendored
@@ -20,7 +20,7 @@ runs:
|
||||
- name: PostgreSQL Logs
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ $RUNNER_DEBUG == '1' ]]; then
|
||||
if [[ $ACTIONS_RUNNER_DEBUG == 'true' || $ACTIONS_STEP_DEBUG == 'true' ]]; then
|
||||
docker stop setup-postgresql-1
|
||||
echo "::group::PostgreSQL Logs"
|
||||
docker logs setup-postgresql-1
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -85,7 +85,6 @@ jobs:
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: lifecycle/container/Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
secrets: |
|
||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||
@@ -96,7 +95,7 @@ jobs:
|
||||
platforms: linux/${{ inputs.image_arch }}
|
||||
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
|
||||
cache-to: ${{ steps.ev.outputs.cacheTo }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
|
||||
4
.github/workflows/_reusable-docker-build.yml
vendored
4
.github/workflows/_reusable-docker-build.yml
vendored
@@ -90,14 +90,14 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: int128/docker-manifest-create-action@6cdd53a8337cd50bc3ef8c7016579d8d460edd94 # v2
|
||||
- uses: int128/docker-manifest-create-action@b60433fd4312d7a64a56d769b76ebe3f45cf36b4 # v2
|
||||
id: build
|
||||
with:
|
||||
tags: ${{ matrix.tag }}
|
||||
sources: |
|
||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
|
||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
|
||||
4
.github/workflows/ci-docs.yml
vendored
4
.github/workflows/ci-docs.yml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
|
||||
2
.github/workflows/ci-main-daily.yml
vendored
2
.github/workflows/ci-main-daily.yml
vendored
@@ -24,5 +24,5 @@ jobs:
|
||||
dir="/tmp/authentik/${{ matrix.version }}"
|
||||
mkdir -p $dir
|
||||
cd $dir
|
||||
wget https://${{ matrix.version }}.goauthentik.io/compose.yml
|
||||
wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
|
||||
${current}/scripts/test_docker.sh
|
||||
|
||||
52
.github/workflows/ci-main.yml
vendored
52
.github/workflows/ci-main.yml
vendored
@@ -193,15 +193,13 @@ jobs:
|
||||
glob: tests/e2e/test_source_scim*
|
||||
- name: flows
|
||||
glob: tests/e2e/test_flows*
|
||||
- name: endpoints
|
||||
glob: tests/e2e/test_endpoints_*
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
run: |
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
with:
|
||||
@@ -223,54 +221,6 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
flags: e2e
|
||||
test-openid-conformance:
|
||||
name: test-openid-conformance (${{ matrix.job.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: basic
|
||||
glob: tests/openid_conformance/test_basic.py
|
||||
- name: implicit
|
||||
glob: tests/openid_conformance/test_implicit.py
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
run: |
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
- name: Setup conformance suite
|
||||
run: |
|
||||
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
make -C .. gen-client-ts
|
||||
npm run build
|
||||
npm run build:sfe
|
||||
- name: run conformance
|
||||
run: |
|
||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||
uv run coverage xml
|
||||
- uses: ./.github/actions/test-results
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
flags: conformance
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: conformance-certification-${{ matrix.job.name }}
|
||||
path: tests/openid_conformance/exports/
|
||||
ci-core-mark:
|
||||
if: always()
|
||||
needs:
|
||||
|
||||
6
.github/workflows/ci-outpost.yml
vendored
6
.github/workflows/ci-outpost.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
|
||||
12
.github/workflows/release-publish.yml
vendored
12
.github/workflows/release-publish.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: true
|
||||
with:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -121,10 +121,10 @@ jobs:
|
||||
build-args: |
|
||||
VERSION=${{ github.ref }}
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
||||
docker cp ${container}:web/ .
|
||||
- name: Create a Sentry.io release
|
||||
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3
|
||||
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -211,5 +211,4 @@ source_docs/
|
||||
/vendor/
|
||||
|
||||
### Docker ###
|
||||
tests/openid_conformance/exports/*.zip
|
||||
compose.override.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -16,8 +16,10 @@ go.sum @goauthentik/backend
|
||||
# Infrastructure
|
||||
.github/ @goauthentik/infrastructure
|
||||
lifecycle/aws/ @goauthentik/infrastructure
|
||||
lifecycle/container/ @goauthentik/infrastructure
|
||||
Dockerfile @goauthentik/infrastructure
|
||||
*Dockerfile @goauthentik/infrastructure
|
||||
.dockerignore @goauthentik/infrastructure
|
||||
docker-compose.yml @goauthentik/infrastructure
|
||||
Makefile @goauthentik/infrastructure
|
||||
.editorconfig @goauthentik/infrastructure
|
||||
CODEOWNERS @goauthentik/infrastructure
|
||||
@@ -38,7 +40,7 @@ packages/tsconfig @goauthentik/frontend
|
||||
# Web
|
||||
web/ @goauthentik/frontend
|
||||
# Locale
|
||||
/locale/ @goauthentik/backend @goauthentik/frontend
|
||||
locale/ @goauthentik/backend @goauthentik/frontend
|
||||
web/xliff/ @goauthentik/backend @goauthentik/frontend
|
||||
# Docs
|
||||
website/ @goauthentik/docs
|
||||
|
||||
16
Makefile
16
Makefile
@@ -141,10 +141,14 @@ gen-build: ## Extract the schema from the database
|
||||
AUTHENTIK_DEBUG=true \
|
||||
AUTHENTIK_TENANTS__ENABLED=true \
|
||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||
uv run ak build_schema
|
||||
uv run ak make_blueprint_schema --file blueprints/schema.json
|
||||
AUTHENTIK_DEBUG=true \
|
||||
AUTHENTIK_TENANTS__ENABLED=true \
|
||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||
uv run ak spectacular --file schema.yml
|
||||
|
||||
gen-compose:
|
||||
uv run scripts/generate_compose.py
|
||||
uv run scripts/generate_docker_compose.py
|
||||
|
||||
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
|
||||
@@ -152,7 +156,7 @@ gen-changelog: ## (Release) generate the changelog based from the commits since
|
||||
|
||||
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
|
||||
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > schema-old.yml
|
||||
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
|
||||
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" diff \
|
||||
--markdown \
|
||||
/local/diff.md \
|
||||
/local/schema-old.yml \
|
||||
@@ -175,7 +179,7 @@ gen-clean-go: ## Remove generated API client for Go
|
||||
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
|
||||
|
||||
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
|
||||
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" gen \
|
||||
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" gen \
|
||||
generate \
|
||||
-i /local/schema.yml \
|
||||
-g typescript-fetch \
|
||||
@@ -296,7 +300,7 @@ docs-api-clean: ## Clean generated API documentation
|
||||
|
||||
docker: ## Build a docker image of the current source tree
|
||||
mkdir -p ${GEN_API_TS}
|
||||
DOCKER_BUILDKIT=1 docker build . -f lifecycle/container/Dockerfile --progress plain --tag ${DOCKER_IMAGE}
|
||||
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
|
||||
|
||||
test-docker:
|
||||
BUILD=true ${PWD}/scripts/test_docker.sh
|
||||
@@ -330,6 +334,6 @@ ci-pending-migrations: ci--meta-debug
|
||||
uv run ak makemigrations --check
|
||||
|
||||
ci-test: ci--meta-debug
|
||||
uv run coverage run manage.py test --keepdb authentik
|
||||
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
|
||||
uv run coverage report
|
||||
uv run coverage xml
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
from json import dumps
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from drf_spectacular.drainage import GENERATOR_STATS
|
||||
from drf_spectacular.generators import SchemaGenerator
|
||||
from drf_spectacular.renderers import OpenApiYamlRenderer
|
||||
from drf_spectacular.validation import validate_schema
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.v1.schema import SchemaBuilder
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logger = get_logger()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--blueprint-file", type=str, default="blueprints/schema.json")
|
||||
parser.add_argument("--api-file", type=str, default="schema.yml")
|
||||
|
||||
@no_translations
|
||||
def handle(self, *args, blueprint_file: str, api_file: str, **options):
|
||||
self.build_blueprint(blueprint_file)
|
||||
self.build_api(api_file)
|
||||
|
||||
def build_blueprint(self, file: str):
|
||||
self.logger.debug("Building blueprint schema...", file=file)
|
||||
blueprint_builder = SchemaBuilder()
|
||||
blueprint_builder.build()
|
||||
with open(file, "w") as _schema:
|
||||
_schema.write(
|
||||
dumps(blueprint_builder.schema, indent=4, default=SchemaBuilder.json_default)
|
||||
)
|
||||
|
||||
def build_api(self, file: str):
|
||||
self.logger.debug("Building API schema...", file=file)
|
||||
generator = SchemaGenerator()
|
||||
schema = generator.get_schema(request=None, public=True)
|
||||
GENERATOR_STATS.emit_summary()
|
||||
validate_schema(schema)
|
||||
output = OpenApiYamlRenderer().render(schema, renderer_context={})
|
||||
with open(file, "wb") as f:
|
||||
f.write(output)
|
||||
@@ -1,14 +1,9 @@
|
||||
"""Schema generation tests"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from yaml import safe_load
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestSchemaGeneration(APITestCase):
|
||||
"""Generic admin tests"""
|
||||
@@ -26,18 +21,3 @@ class TestSchemaGeneration(APITestCase):
|
||||
reverse("authentik_api:schema-browser"),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_build_schema(self):
|
||||
"""Test schema build command"""
|
||||
blueprint_file = Path("blueprints/schema.json")
|
||||
api_file = Path("schema.yml")
|
||||
blueprint_file.unlink()
|
||||
api_file.unlink()
|
||||
with (
|
||||
CONFIG.patch("debug", True),
|
||||
CONFIG.patch("tenants.enabled", True),
|
||||
CONFIG.patch("outposts.disable_embedded_outpost", True),
|
||||
):
|
||||
call_command("build_schema")
|
||||
self.assertTrue(blueprint_file.exists())
|
||||
self.assertTrue(api_file.exists())
|
||||
|
||||
@@ -31,7 +31,6 @@ class Capabilities(models.TextChoices):
|
||||
"""Define capabilities which influence which APIs can/should be used"""
|
||||
|
||||
CAN_SAVE_MEDIA = "can_save_media"
|
||||
CAN_SAVE_REPORTS = "can_save_reports"
|
||||
CAN_GEO_IP = "can_geo_ip"
|
||||
CAN_ASN = "can_asn"
|
||||
CAN_IMPERSONATE = "can_impersonate"
|
||||
@@ -71,8 +70,6 @@ class ConfigView(APIView):
|
||||
caps = []
|
||||
if get_file_manager(FileUsage.MEDIA).manageable:
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
if get_file_manager(FileUsage.REPORTS).manageable:
|
||||
caps.append(Capabilities.CAN_SAVE_REPORTS)
|
||||
for processor in get_context_processors():
|
||||
if cap := processor.capability():
|
||||
caps.append(cap)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Generate JSON Schema for blueprints"""
|
||||
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from django.db.models import Model, fields
|
||||
from django.db.models.fields.related import OneToOneField
|
||||
from drf_jsonschema_serializer.convert import converter, field_to_converter
|
||||
@@ -38,12 +40,13 @@ class PrimaryKeyRelatedFieldConverter:
|
||||
return {"type": "integer"}
|
||||
|
||||
|
||||
class SchemaBuilder:
|
||||
class Command(BaseCommand):
|
||||
"""Generate JSON Schema for blueprints"""
|
||||
|
||||
schema: dict
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
@@ -90,6 +93,16 @@ class SchemaBuilder:
|
||||
"$defs": {"blueprint_entry": {"oneOf": []}},
|
||||
}
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--file", type=str)
|
||||
|
||||
@no_translations
|
||||
def handle(self, *args, file: str, **options):
|
||||
"""Generate JSON Schema for blueprints"""
|
||||
self.build()
|
||||
with open(file, "w") as _schema:
|
||||
_schema.write(dumps(self.schema, indent=4, default=Command.json_default))
|
||||
|
||||
@staticmethod
|
||||
def json_default(value: Any) -> Any:
|
||||
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
|
||||
@@ -111,7 +124,7 @@ class SchemaBuilder:
|
||||
try:
|
||||
serializer_class = model_instance.serializer
|
||||
except NotImplementedError as exc:
|
||||
raise ValueError(f"SerializerModel not implemented by {model}") from exc
|
||||
raise NotImplementedError(model_instance) from exc
|
||||
serializer = serializer_class(
|
||||
context={
|
||||
SERIALIZER_CONTEXT_BLUEPRINT: False,
|
||||
@@ -8,62 +8,45 @@ metadata:
|
||||
- Application (icon)
|
||||
- Source (icon)
|
||||
- Flow (background)
|
||||
- Endpoint Enrollment token (key)
|
||||
entries:
|
||||
token:
|
||||
- model: authentik_core.token
|
||||
identifiers:
|
||||
identifier: "%(uid)s-token"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
user: "%(user)s"
|
||||
intent: api
|
||||
app:
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: "%(uid)s-app"
|
||||
attrs:
|
||||
name: "%(uid)s-app"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
source:
|
||||
- model: authentik_sources_oauth.oauthsource
|
||||
identifiers:
|
||||
slug: "%(uid)s-source"
|
||||
attrs:
|
||||
name: "%(uid)s-source"
|
||||
provider_type: azuread
|
||||
consumer_key: "%(uid)s"
|
||||
consumer_secret: "%(uid)s"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
flow:
|
||||
- model: authentik_flows.flow
|
||||
identifiers:
|
||||
slug: "%(uid)s-flow"
|
||||
attrs:
|
||||
name: "%(uid)s-flow"
|
||||
title: "%(uid)s-flow"
|
||||
designation: authentication
|
||||
background: https://goauthentik.io/img/icon.png
|
||||
user:
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
password: "%(uid)s"
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s-no-password"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
endpoint:
|
||||
- model: authentik_endpoints_connectors_agent.agentconnector
|
||||
id: connector
|
||||
identifiers:
|
||||
name: "%(uid)s"
|
||||
- model: authentik_endpoints_connectors_agent.enrollmenttoken
|
||||
identifiers:
|
||||
name: "%(uid)s"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
connector: !KeyOf connector
|
||||
- model: authentik_core.token
|
||||
identifiers:
|
||||
identifier: "%(uid)s-token"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
user: "%(user)s"
|
||||
intent: api
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: "%(uid)s-app"
|
||||
attrs:
|
||||
name: "%(uid)s-app"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_sources_oauth.oauthsource
|
||||
identifiers:
|
||||
slug: "%(uid)s-source"
|
||||
attrs:
|
||||
name: "%(uid)s-source"
|
||||
provider_type: azuread
|
||||
consumer_key: "%(uid)s"
|
||||
consumer_secret: "%(uid)s"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_flows.flow
|
||||
identifiers:
|
||||
slug: "%(uid)s-flow"
|
||||
attrs:
|
||||
name: "%(uid)s-flow"
|
||||
title: "%(uid)s-flow"
|
||||
designation: authentication
|
||||
background: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
password: "%(uid)s"
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s-no-password"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.test import TransactionTestCase
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.models import Token, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.endpoints.connectors.agent.models import EnrollmentToken
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
|
||||
@@ -30,18 +29,12 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||
|
||||
def test_user(self):
|
||||
"""Test user"""
|
||||
user = User.objects.filter(username=self.uid).first()
|
||||
user: User = User.objects.filter(username=self.uid).first()
|
||||
self.assertIsNotNone(user)
|
||||
self.assertTrue(user.check_password(self.uid))
|
||||
|
||||
def test_user_null(self):
|
||||
"""Test user"""
|
||||
user = User.objects.filter(username=f"{self.uid}-no-password").first()
|
||||
user: User = User.objects.filter(username=f"{self.uid}-no-password").first()
|
||||
self.assertIsNotNone(user)
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
def test_enrollment_token(self):
|
||||
"""Test endpoint enrollment token"""
|
||||
token = EnrollmentToken.objects.filter(name=self.uid).first()
|
||||
self.assertIsNotNone(token)
|
||||
self.assertEqual(token.key, self.uid)
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
|
||||
instance.status,
|
||||
BlueprintInstanceStatus.UNKNOWN,
|
||||
)
|
||||
apply_blueprint.send(instance.pk).get_result(block=True)
|
||||
apply_blueprint(instance.pk)
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.last_applied_hash, "")
|
||||
self.assertEqual(
|
||||
|
||||
@@ -15,6 +15,7 @@ from django.db.models import Model
|
||||
from django.db.models.query_utils import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django_channels_postgres.models import GroupChannel, Message
|
||||
from guardian.models import RoleObjectPermission, UserObjectPermission
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
@@ -40,17 +41,55 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.endpoints.models import Connector
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
AgentDeviceConnection,
|
||||
AppleNonce,
|
||||
DeviceAuthenticationToken,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceToken as EndpointDeviceToken,
|
||||
)
|
||||
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import LicenseUsage
|
||||
from authentik.enterprise.providers.google_workspace.models import (
|
||||
GoogleWorkspaceProviderGroup,
|
||||
GoogleWorkspaceProviderUser,
|
||||
)
|
||||
from authentik.enterprise.providers.microsoft_entra.models import (
|
||||
MicrosoftEntraProviderGroup,
|
||||
MicrosoftEntraProviderUser,
|
||||
)
|
||||
from authentik.enterprise.providers.ssf.models import StreamEvent
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
|
||||
EndpointDevice,
|
||||
EndpointDeviceConnection,
|
||||
)
|
||||
from authentik.events.logs import LogEvent, capture_logs
|
||||
from authentik.events.utils import cleanse_dict
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.flows.models import FlowToken, Stage
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
from authentik.outposts.models import OutpostServiceConnection
|
||||
from authentik.policies.models import Policy, PolicyBindingModel
|
||||
from authentik.policies.reputation.models import Reputation
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
DeviceToken,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.proxy.models import ProxySession
|
||||
from authentik.providers.rac.models import ConnectionToken
|
||||
from authentik.providers.saml.models import SAMLSession
|
||||
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
|
||||
from authentik.rbac.models import Role
|
||||
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
|
||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
|
||||
from authentik.stages.consent.models import UserConsent
|
||||
from authentik.tasks.models import Task, TaskLog
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
# Context set when the serializer is created in a blueprint context
|
||||
# Update website/docs/customize/blueprints/v1/models.md when used
|
||||
@@ -86,16 +125,49 @@ def excluded_models() -> list[type[Model]]:
|
||||
# Classes that have other dependencies
|
||||
Session,
|
||||
AuthenticatedSession,
|
||||
# Classes which are only internally managed
|
||||
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
|
||||
FlowToken,
|
||||
LicenseUsage,
|
||||
SCIMProviderGroup,
|
||||
SCIMProviderUser,
|
||||
Tenant,
|
||||
Task,
|
||||
TaskLog,
|
||||
ConnectionToken,
|
||||
AuthorizationCode,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
ProxySession,
|
||||
Reputation,
|
||||
WebAuthnDeviceType,
|
||||
SCIMSourceUser,
|
||||
SCIMSourceGroup,
|
||||
GoogleWorkspaceProviderUser,
|
||||
GoogleWorkspaceProviderGroup,
|
||||
MicrosoftEntraProviderUser,
|
||||
MicrosoftEntraProviderGroup,
|
||||
EndpointDevice,
|
||||
EndpointDeviceConnection,
|
||||
EndpointDeviceToken,
|
||||
Device,
|
||||
DeviceConnection,
|
||||
DeviceAuthenticationToken,
|
||||
AppleNonce,
|
||||
AgentDeviceConnection,
|
||||
DeviceFactSnapshot,
|
||||
DeviceToken,
|
||||
StreamEvent,
|
||||
UserConsent,
|
||||
SAMLSession,
|
||||
Message,
|
||||
GroupChannel,
|
||||
)
|
||||
|
||||
|
||||
def is_model_allowed(model: type[Model]) -> bool:
|
||||
"""Check if model is allowed"""
|
||||
return (
|
||||
model not in excluded_models()
|
||||
and issubclass(model, SerializerModel | BaseMetaModel)
|
||||
and not issubclass(model, InternallyManagedMixin)
|
||||
)
|
||||
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
|
||||
|
||||
|
||||
class DoRollback(SentryIgnoredException):
|
||||
|
||||
@@ -37,21 +37,14 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
def create(self, validated_data: dict) -> MetaResult:
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.blueprints.v1.tasks import apply_blueprint
|
||||
|
||||
if not self.blueprint_instance:
|
||||
LOGGER.info("Blueprint does not exist, but not required")
|
||||
return MetaResult()
|
||||
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
|
||||
|
||||
# Apply blueprint directly using Importer to avoid task context requirements
|
||||
# and prevent deadlocks when called from within another blueprint task
|
||||
blueprint_content = self.blueprint_instance.retrieve()
|
||||
importer = Importer.from_string(blueprint_content, self.blueprint_instance.context)
|
||||
valid, logs = importer.validate()
|
||||
[log.log() for log in logs]
|
||||
if valid:
|
||||
importer.apply()
|
||||
apply_blueprint(self.blueprint_instance.pk)
|
||||
return MetaResult()
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_dramatiq_postgres.middleware import CurrentTaskNotFound
|
||||
from dramatiq.actor import actor
|
||||
from dramatiq.middleware import Middleware
|
||||
from structlog.stdlib import get_logger
|
||||
@@ -39,6 +40,7 @@ from authentik.events.utils import sanitize_dict
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.tasks.apps import PRIORITY_HIGH
|
||||
from authentik.tasks.middleware import CurrentTask
|
||||
from authentik.tasks.models import Task
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -189,7 +191,10 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
|
||||
|
||||
@actor(description=_("Apply single blueprint."))
|
||||
def apply_blueprint(instance_pk: UUID):
|
||||
self = CurrentTask.get_task()
|
||||
try:
|
||||
self = CurrentTask.get_task()
|
||||
except CurrentTaskNotFound:
|
||||
self = Task()
|
||||
self.set_uid(str(instance_pk))
|
||||
instance: BlueprintInstance | None = None
|
||||
try:
|
||||
|
||||
@@ -256,7 +256,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
return [
|
||||
StrField(Group, "name"),
|
||||
BoolField(Group, "is_superuser", nullable=True),
|
||||
JSONSearchField(Group, "attributes"),
|
||||
JSONSearchField(Group, "attributes", suggest_nested=False),
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -518,7 +518,7 @@ class UserViewSet(
|
||||
StrField(User, "path"),
|
||||
BoolField(User, "is_active", nullable=True),
|
||||
ChoiceSearchField(User, "type"),
|
||||
JSONSearchField(User, "attributes"),
|
||||
JSONSearchField(User, "attributes", suggest_nested=False),
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -66,12 +66,9 @@ class SessionStore(SessionBase):
|
||||
def decode(self, session_data):
|
||||
try:
|
||||
return pickle.loads(session_data) # nosec
|
||||
except (pickle.PickleError, AttributeError, TypeError):
|
||||
# PickleError, ValueError - unpickling exceptions
|
||||
# AttributeError - can happen when Django model fields (e.g., FileField) are unpickled
|
||||
# and their descriptors fail to initialize (e.g., missing storage)
|
||||
# TypeError - can happen with incompatible pickled objects
|
||||
# If any of these happen, just return an empty dictionary (an empty session)
|
||||
except pickle.PickleError:
|
||||
# ValueError, unpickling exceptions. If any of these happen, just return an empty
|
||||
# dictionary (an empty session)
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
@@ -35,13 +35,8 @@ def clean_expired_models():
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
clear_expired_cache()
|
||||
for cls in [Message, GroupChannel]:
|
||||
objects = cls.objects.all().filter(expires__lt=now())
|
||||
amount = objects.count()
|
||||
for obj in chunked_queryset(objects):
|
||||
obj.delete()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
Message.delete_expired()
|
||||
GroupChannel.delete_expired()
|
||||
|
||||
|
||||
@actor(description=_("Remove temporary users created by SAML Sources."))
|
||||
|
||||
@@ -5,10 +5,9 @@ from django.test import TestCase
|
||||
from authentik.core.models import Group, PropertyMapping, Source, User
|
||||
from authentik.core.sources.mapper import SourceMapper
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.models import InternallyManagedMixin
|
||||
|
||||
|
||||
class ProxySource(InternallyManagedMixin, Source):
|
||||
class ProxySource(Source):
|
||||
@property
|
||||
def property_mapping_type(self):
|
||||
return PropertyMapping
|
||||
|
||||
@@ -7,17 +7,14 @@ from cryptography.x509 import load_pem_x509_certificate
|
||||
from django.db import migrations, models
|
||||
|
||||
from authentik.crypto.signals import extract_certificate_metadata
|
||||
from authentik.lib.migrations import progress_bar
|
||||
|
||||
|
||||
def backfill_certificate_metadata(apps, schema_editor): # noqa: ARG001
|
||||
"""Backfill certificate metadata and kid for existing records."""
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
||||
|
||||
print("\nStoring extra data about certificates, this might take a couple of minutes...")
|
||||
for cert in progress_bar(CertificateKeyPair.objects.using(db_alias).all()):
|
||||
for cert in CertificateKeyPair.objects.all():
|
||||
updated_fields = []
|
||||
|
||||
if cert.certificate_data:
|
||||
@@ -50,7 +47,7 @@ def backfill_certificate_metadata(apps, schema_editor): # noqa: ARG001
|
||||
updated_fields.append("kid")
|
||||
|
||||
if updated_fields:
|
||||
cert.save(update_fields=updated_fields, using=db_alias)
|
||||
cert.save(update_fields=updated_fields)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from typing import cast
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import ChoiceField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@@ -24,9 +22,6 @@ from authentik.endpoints.connectors.agent.api.agent import (
|
||||
from authentik.endpoints.connectors.agent.auth import (
|
||||
AgentAuth,
|
||||
AgentEnrollmentAuth,
|
||||
DeviceAuthFedAuthentication,
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.controller import MDMConfigResponseSerializer
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
@@ -37,10 +32,7 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
)
|
||||
from authentik.endpoints.facts import DeviceFacts, OSFamily
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
|
||||
class AgentConnectorSerializer(ConnectorSerializer):
|
||||
@@ -171,43 +163,3 @@ class AgentConnectorViewSet(
|
||||
connection: AgentDeviceConnection = token.device
|
||||
connection.create_snapshot(data.validated_data)
|
||||
return Response(status=204)
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
|
||||
responses={
|
||||
200: AgentTokenResponseSerializer(),
|
||||
404: OpenApiResponse(description="Device not found"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
authentication_classes=[DeviceAuthFedAuthentication],
|
||||
)
|
||||
def auth_fed(self, request: Request) -> Response:
|
||||
federated_token, device, connector = request.auth
|
||||
|
||||
policy_result = check_device_policies(device, federated_token.user, request._request)
|
||||
if not policy_result.passing:
|
||||
raise ValidationError(
|
||||
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
|
||||
)
|
||||
|
||||
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
|
||||
rel_exp = int((exp - now()).total_seconds())
|
||||
Event.new(
|
||||
EventAction.LOGIN,
|
||||
**{
|
||||
PLAN_CONTEXT_METHOD: "jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"jwt": federated_token,
|
||||
"provider": federated_token.provider,
|
||||
},
|
||||
PLAN_CONTEXT_DEVICE: device,
|
||||
},
|
||||
).from_http(request, user=federated_token.user)
|
||||
return Response({"token": token, "expires_in": rel_exp})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.tokens import TokenViewSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
@@ -21,11 +19,6 @@ class EnrollmentTokenSerializer(ModelSerializer):
|
||||
source="device_group", read_only=True, required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["key"] = CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = EnrollmentToken
|
||||
fields = [
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import IPCUser, validate_auth
|
||||
from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
PLATFORM_ISSUER = "goauthentik.io/platform"
|
||||
from authentik.endpoints.connectors.agent.models import DeviceToken, EnrollmentToken
|
||||
|
||||
|
||||
class DeviceUser(IPCUser):
|
||||
@@ -55,96 +40,3 @@ class AgentAuth(BaseAuthentication):
|
||||
raise PermissionDenied()
|
||||
CTX_AUTH_VIA.set("endpoint_token")
|
||||
return (DeviceUser(), device_token)
|
||||
|
||||
|
||||
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
|
||||
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
|
||||
if not kp:
|
||||
return None, None
|
||||
exp = now() + timedelta_from_string(connector.auth_session_duration)
|
||||
token = encode(
|
||||
{
|
||||
"iss": PLATFORM_ISSUER,
|
||||
"aud": str(device.pk),
|
||||
"iat": int(now().timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"preferred_username": user.username,
|
||||
**kwargs,
|
||||
},
|
||||
kp.private_key,
|
||||
headers={
|
||||
"kid": kp.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
|
||||
)
|
||||
return token, exp
|
||||
|
||||
|
||||
class DeviceAuthFedAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = validate_auth(get_authorization_header(request))
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing token")
|
||||
return None
|
||||
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
|
||||
if not device:
|
||||
LOGGER.warning("Couldn't find device")
|
||||
return None
|
||||
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
|
||||
connector = connectors_for_device.first()
|
||||
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
|
||||
federated_token = AccessToken.objects.filter(
|
||||
token=raw_token, provider__in=providers
|
||||
).first()
|
||||
if not federated_token:
|
||||
LOGGER.warning("Couldn't lookup provider")
|
||||
return None
|
||||
_key, _alg = federated_token.provider.jwt_key
|
||||
try:
|
||||
decode(
|
||||
raw_token,
|
||||
_key.public_key(),
|
||||
algorithms=[_alg],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"successfully verified JWT with provider", provider=federated_token.provider.name
|
||||
)
|
||||
return (federated_token.user, (federated_token, device, connector))
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
|
||||
return None
|
||||
|
||||
|
||||
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
|
||||
"""Auth schema"""
|
||||
|
||||
target_class = DeviceAuthFedAuthentication
|
||||
name = "device_federation"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""Auth schema"""
|
||||
return {"type": "http", "scheme": "bearer"}
|
||||
|
||||
|
||||
def check_device_policies(device: Device, user: User, request: HttpRequest):
|
||||
"""Check policies bound to device group and device"""
|
||||
if device.access_group:
|
||||
result = check_pbm_policies(device.access_group, user, request)
|
||||
if result.passing:
|
||||
return result
|
||||
return check_pbm_policies(device, user, request)
|
||||
|
||||
|
||||
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
|
||||
policy_engine = PolicyEngine(pbm, user, request)
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.empty_result = False
|
||||
policy_engine.mode = pbm.policy_engine_mode
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
|
||||
return result
|
||||
|
||||
@@ -16,7 +16,7 @@ from authentik.endpoints.models import (
|
||||
)
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -97,7 +97,7 @@ class AgentDeviceUserBinding(DeviceUserBinding):
|
||||
apple_enclave_key_id = models.TextField()
|
||||
|
||||
|
||||
class DeviceToken(InternallyManagedMixin, ExpiringModel):
|
||||
class DeviceToken(ExpiringModel):
|
||||
"""Per-device token used for authentication."""
|
||||
|
||||
token_uuid = models.UUIDField(primary_key=True, default=uuid4)
|
||||
@@ -143,7 +143,7 @@ class EnrollmentToken(ExpiringModel, SerializerModel):
|
||||
]
|
||||
|
||||
|
||||
class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
|
||||
class DeviceAuthenticationToken(ExpiringModel):
|
||||
|
||||
identifier = models.UUIDField(default=uuid4, primary_key=True)
|
||||
device = models.ForeignKey(Device, on_delete=models.CASCADE)
|
||||
@@ -160,7 +160,7 @@ class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
|
||||
verbose_name_plural = _("Device authentication tokens")
|
||||
|
||||
|
||||
class AppleNonce(InternallyManagedMixin, ExpiringModel):
|
||||
class AppleNonce(ExpiringModel):
|
||||
nonce = models.TextField()
|
||||
device_token = models.ForeignKey(DeviceToken, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from authentik.core.models import AttributesMixin, ExpiringModel
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
from authentik.lib.models import InheritanceForeignKey, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.policies.models import PolicyBinding, PolicyBindingModel
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
@@ -28,7 +28,7 @@ LOGGER = get_logger()
|
||||
DEVICE_FACTS_CACHE_TIMEOUT = 3600
|
||||
|
||||
|
||||
class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindingModel):
|
||||
class Device(ExpiringModel, AttributesMixin, PolicyBindingModel):
|
||||
device_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
@@ -86,7 +86,7 @@ class DeviceUserBinding(PolicyBinding):
|
||||
verbose_name_plural = _("Device User bindings")
|
||||
|
||||
|
||||
class DeviceConnection(InternallyManagedMixin, SerializerModel):
|
||||
class DeviceConnection(SerializerModel):
|
||||
device_connection_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
device = models.ForeignKey("Device", on_delete=models.CASCADE)
|
||||
connector = models.ForeignKey("Connector", on_delete=models.CASCADE)
|
||||
@@ -115,7 +115,7 @@ class DeviceConnection(InternallyManagedMixin, SerializerModel):
|
||||
verbose_name_plural = _("Device connections")
|
||||
|
||||
|
||||
class DeviceFactSnapshot(InternallyManagedMixin, ExpiringModel, SerializerModel):
|
||||
class DeviceFactSnapshot(ExpiringModel, SerializerModel):
|
||||
snapshot_id = models.UUIDField(primary_key=True, default=uuid4)
|
||||
connection = models.ForeignKey(DeviceConnection, on_delete=models.CASCADE)
|
||||
data = models.JSONField(default=dict)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Enterprise API Views"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -37,18 +35,6 @@ class EnterpriseRequiredMixin:
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
def enterprise_action(func: Callable):
|
||||
"""Check permissions for a single custom action"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Response:
|
||||
if not LicenseKey.cached_summary().status.is_valid:
|
||||
raise ValidationError(_("Enterprise is required to use this endpoint."))
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class LicenseSerializer(ModelSerializer):
|
||||
"""License Serializer"""
|
||||
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.endpoints.connectors.agent.api.agent import (
|
||||
AgentAuthenticationResponse,
|
||||
AgentTokenResponseSerializer,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceAuthenticationToken,
|
||||
DeviceToken,
|
||||
)
|
||||
from authentik.enterprise.api import enterprise_action
|
||||
from authentik.enterprise.endpoints.connectors.agent.auth import (
|
||||
DeviceAuthFedAuthentication,
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -26,7 +37,6 @@ class AgentConnectorViewSetMixin:
|
||||
responses=AgentAuthenticationResponse(),
|
||||
)
|
||||
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
|
||||
@enterprise_action
|
||||
def auth_ia(self, request: Request) -> Response:
|
||||
token: DeviceToken = request.auth
|
||||
auth_token = DeviceAuthenticationToken.objects.create(
|
||||
@@ -44,3 +54,43 @@ class AgentConnectorViewSetMixin:
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
|
||||
responses={
|
||||
200: AgentTokenResponseSerializer(),
|
||||
404: OpenApiResponse(description="Device not found"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
authentication_classes=[DeviceAuthFedAuthentication],
|
||||
)
|
||||
def auth_fed(self, request: Request) -> Response:
|
||||
federated_token, device, connector = request.auth
|
||||
|
||||
policy_result = check_device_policies(device, federated_token.user, request._request)
|
||||
if not policy_result.passing:
|
||||
raise ValidationError(
|
||||
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
|
||||
)
|
||||
|
||||
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
|
||||
rel_exp = int((exp - now()).total_seconds())
|
||||
Event.new(
|
||||
EventAction.LOGIN,
|
||||
**{
|
||||
PLAN_CONTEXT_METHOD: "jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"jwt": federated_token,
|
||||
"provider": federated_token.provider,
|
||||
},
|
||||
PLAN_CONTEXT_DEVICE: device,
|
||||
},
|
||||
).from_http(request, user=federated_token.user)
|
||||
return Response({"token": token, "expires_in": rel_exp})
|
||||
|
||||
113
authentik/enterprise/endpoints/connectors/agent/auth.py
Normal file
113
authentik/enterprise/endpoints/connectors/agent/auth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import get_authorization_header, validate_auth
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
PLATFORM_ISSUER = "goauthentik.io/platform"
|
||||
|
||||
|
||||
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
|
||||
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
|
||||
if not kp:
|
||||
return None, None
|
||||
exp = now() + timedelta_from_string(connector.auth_session_duration)
|
||||
token = encode(
|
||||
{
|
||||
"iss": PLATFORM_ISSUER,
|
||||
"aud": str(device.pk),
|
||||
"iat": int(now().timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"preferred_username": user.username,
|
||||
**kwargs,
|
||||
},
|
||||
kp.private_key,
|
||||
headers={
|
||||
"kid": kp.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
|
||||
)
|
||||
return token, exp
|
||||
|
||||
|
||||
class DeviceAuthFedAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = validate_auth(get_authorization_header(request))
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing token")
|
||||
return None
|
||||
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
|
||||
if not device:
|
||||
LOGGER.warning("Couldn't find device")
|
||||
return None
|
||||
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
|
||||
connector = connectors_for_device.first()
|
||||
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
|
||||
federated_token = AccessToken.objects.filter(
|
||||
token=raw_token, provider__in=providers
|
||||
).first()
|
||||
if not federated_token:
|
||||
LOGGER.warning("Couldn't lookup provider")
|
||||
return None
|
||||
_key, _alg = federated_token.provider.jwt_key
|
||||
try:
|
||||
decode(
|
||||
raw_token,
|
||||
_key.public_key(),
|
||||
algorithms=[_alg],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"successfully verified JWT with provider", provider=federated_token.provider.name
|
||||
)
|
||||
return (federated_token.user, (federated_token, device, connector))
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
|
||||
return None
|
||||
|
||||
|
||||
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
|
||||
"""Auth schema"""
|
||||
|
||||
target_class = DeviceAuthFedAuthentication
|
||||
name = "device_federation"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""Auth schema"""
|
||||
return {"type": "http", "scheme": "bearer"}
|
||||
|
||||
|
||||
def check_device_policies(device: Device, user: User, request: HttpRequest):
|
||||
"""Check policies bound to device group and device"""
|
||||
if device.access_group:
|
||||
result = check_pbm_policies(device.access_group, user, request)
|
||||
if result.passing:
|
||||
return result
|
||||
return check_pbm_policies(device, user, request)
|
||||
|
||||
|
||||
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
|
||||
policy_engine = PolicyEngine(pbm, user, request)
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.empty_result = False
|
||||
policy_engine.mode = pbm.policy_engine_mode
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
|
||||
return result
|
||||
@@ -63,21 +63,8 @@ class TestConnectorAuthIA(FlowTestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=expiry_valid,
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
@reconcile_app("authentik_crypto")
|
||||
def test_auth_ia_fulfill(self):
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:agentconnector-auth-ia"),
|
||||
|
||||
@@ -3,12 +3,12 @@ from hmac import compare_digest
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict
|
||||
|
||||
from authentik.endpoints.connectors.agent.auth import (
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.enterprise.endpoints.connectors.agent.auth import (
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.enterprise.policy import EnterprisePolicyAccessView
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.utils.translation import gettext as _
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.core.models import ExpiringModel
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
@@ -81,7 +81,7 @@ class LicenseUsageStatus(models.TextChoices):
|
||||
return self in [LicenseUsageStatus.VALID, LicenseUsageStatus.EXPIRY_SOON]
|
||||
|
||||
|
||||
class LicenseUsage(InternallyManagedMixin, ExpiringModel):
|
||||
class LicenseUsage(ExpiringModel):
|
||||
"""a single license usage record"""
|
||||
|
||||
expires = models.DateTimeField(default=usage_expiry)
|
||||
|
||||
@@ -18,7 +18,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
|
||||
|
||||
@@ -32,7 +32,7 @@ def default_scopes() -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
class GoogleWorkspaceProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
class GoogleWorkspaceProviderUser(SerializerModel):
|
||||
"""Mapping of a user and provider to a Google user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@@ -58,7 +58,7 @@ class GoogleWorkspaceProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"Google Workspace Provider User {self.user_id} to {self.provider_id}"
|
||||
|
||||
|
||||
class GoogleWorkspaceProviderGroup(InternallyManagedMixin, SerializerModel):
|
||||
class GoogleWorkspaceProviderGroup(SerializerModel):
|
||||
"""Mapping of a group and provider to a Google group ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -18,12 +18,12 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
|
||||
|
||||
|
||||
class MicrosoftEntraProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
class MicrosoftEntraProviderUser(SerializerModel):
|
||||
"""Mapping of a user and provider to a Microsoft user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@@ -49,7 +49,7 @@ class MicrosoftEntraProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}"
|
||||
|
||||
|
||||
class MicrosoftEntraProviderGroup(InternallyManagedMixin, SerializerModel):
|
||||
class MicrosoftEntraProviderGroup(SerializerModel):
|
||||
"""Mapping of a group and provider to a Microsoft group ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -14,7 +14,7 @@ from jwt import encode
|
||||
|
||||
from authentik.core.models import BackchannelProvider, ExpiringModel, Token
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import CreatedUpdatedModel, InternallyManagedMixin
|
||||
from authentik.lib.models import CreatedUpdatedModel
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
||||
from authentik.tasks.models import TasksModel
|
||||
@@ -153,7 +153,7 @@ class Stream(models.Model):
|
||||
return encode(data, key, algorithm=alg, headers=headers)
|
||||
|
||||
|
||||
class StreamEvent(InternallyManagedMixin, CreatedUpdatedModel, ExpiringModel):
|
||||
class StreamEvent(CreatedUpdatedModel, ExpiringModel):
|
||||
"""Single stream event to be sent"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)
|
||||
|
||||
@@ -4,35 +4,37 @@ from django.urls import reverse
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, SerializerMethodField
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.core.api.groups import PartialUserSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.reports.models import DataExport
|
||||
from authentik.enterprise.reports.tasks import generate_export
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
|
||||
|
||||
class RequestedBySerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("pk", "username")
|
||||
|
||||
|
||||
class ContentTypeSerializer(ModelSerializer):
|
||||
app_label = CharField(read_only=True)
|
||||
model = CharField(read_only=True)
|
||||
verbose_name_plural = SerializerMethodField()
|
||||
|
||||
def get_verbose_name_plural(self, ct: ContentType) -> str:
|
||||
return ct.model_class()._meta.verbose_name_plural
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ("id", "app_label", "model", "verbose_name_plural")
|
||||
fields = ("id", "app_label", "model")
|
||||
|
||||
|
||||
class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
requested_by = PartialUserSerializer(read_only=True)
|
||||
requested_by = RequestedBySerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.db import connection
|
||||
from django.db.models import Model, Q
|
||||
from djangoql.compat import text_type
|
||||
from djangoql.schema import StrField
|
||||
from djangoql.serializers import DjangoQLSchemaSerializer
|
||||
|
||||
|
||||
class JSONSearchField(StrField):
|
||||
@@ -15,18 +14,10 @@ class JSONSearchField(StrField):
|
||||
|
||||
model: Model
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model=None,
|
||||
name=None,
|
||||
nullable=None,
|
||||
suggest_nested=False,
|
||||
fixed_structure: OrderedDict | None = None,
|
||||
):
|
||||
def __init__(self, model=None, name=None, nullable=None, suggest_nested=True):
|
||||
# Set this in the constructor to not clobber the type variable
|
||||
self.type = "relation"
|
||||
self.suggest_nested = suggest_nested
|
||||
self.fixed_structure = fixed_structure
|
||||
super().__init__(model, name, nullable)
|
||||
|
||||
def get_lookup(self, path, operator, value):
|
||||
@@ -66,23 +57,11 @@ class JSONSearchField(StrField):
|
||||
)
|
||||
return (x[0] for x in cursor.fetchall())
|
||||
|
||||
def get_fixed_structure(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
|
||||
new_dict = OrderedDict()
|
||||
if not self.fixed_structure:
|
||||
return new_dict
|
||||
new_dict.setdefault(self.relation(), {})
|
||||
for key, value in self.fixed_structure.items():
|
||||
new_dict[self.relation()][key] = serializer.serialize_field(value)
|
||||
if isinstance(value, JSONSearchField):
|
||||
new_dict.update(value.get_nested_options(serializer))
|
||||
return new_dict
|
||||
|
||||
def get_nested_options(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
|
||||
def get_nested_options(self) -> OrderedDict:
|
||||
"""Get keys of all nested objects to show autocomplete"""
|
||||
if not self.suggest_nested:
|
||||
if self.fixed_structure:
|
||||
return self.get_fixed_structure(serializer)
|
||||
return OrderedDict()
|
||||
base_model_name = f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
|
||||
|
||||
def recursive_function(parts: list[str], parent_parts: list[str] | None = None):
|
||||
if not parent_parts:
|
||||
@@ -108,7 +87,7 @@ class JSONSearchField(StrField):
|
||||
relation_structure = defaultdict(dict)
|
||||
|
||||
for relations in self.json_field_keys():
|
||||
result = recursive_function([self.relation()] + relations)
|
||||
result = recursive_function([base_model_name] + relations)
|
||||
for relation_key, value in result.items():
|
||||
for sub_relation_key, sub_value in value.items():
|
||||
if not relation_structure[relation_key].get(sub_relation_key, None):
|
||||
|
||||
@@ -12,7 +12,7 @@ class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
|
||||
for _, field in fields.items():
|
||||
if not isinstance(field, JSONSearchField):
|
||||
continue
|
||||
serialization["models"].update(field.get_nested_options(self))
|
||||
serialization["models"].update(field.get_nested_options())
|
||||
return serialization
|
||||
|
||||
def serialize_field(self, field):
|
||||
|
||||
@@ -11,7 +11,7 @@ from rest_framework.serializers import BaseSerializer, Serializer
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.models import DeprecatedMixin, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import DeprecatedMixin, SerializerModel
|
||||
from authentik.stages.authenticator.models import Device
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class AuthenticatorEndpointGDTCStage(DeprecatedMixin, ConfigurableStage, Friendl
|
||||
verbose_name_plural = _("Endpoint Authenticator Google Device Trust Connector Stages")
|
||||
|
||||
|
||||
class EndpointDevice(InternallyManagedMixin, SerializerModel, Device):
|
||||
class EndpointDevice(SerializerModel, Device):
|
||||
"""Endpoint Device for a single user"""
|
||||
|
||||
uuid = models.UUIDField(primary_key=True, default=uuid4)
|
||||
@@ -91,7 +91,7 @@ class EndpointDevice(InternallyManagedMixin, SerializerModel, Device):
|
||||
verbose_name_plural = _("Endpoint Devices")
|
||||
|
||||
|
||||
class EndpointDeviceConnection(InternallyManagedMixin, models.Model):
|
||||
class EndpointDeviceConnection(models.Model):
|
||||
device = models.ForeignKey(EndpointDevice, on_delete=models.CASCADE)
|
||||
stage = models.ForeignKey(AuthenticatorEndpointGDTCStage, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Events API Views"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
|
||||
import django_filters
|
||||
@@ -137,7 +136,7 @@ class EventViewSet(
|
||||
filterset_class = EventsFilter
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import DateTimeField, IntField, StrField
|
||||
from djangoql.schema import DateTimeField, StrField
|
||||
|
||||
from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField
|
||||
|
||||
@@ -146,42 +145,9 @@ class EventViewSet(
|
||||
StrField(Event, "event_uuid"),
|
||||
StrField(Event, "app", suggest_options=True),
|
||||
StrField(Event, "client_ip"),
|
||||
JSONSearchField(
|
||||
Event,
|
||||
"user",
|
||||
fixed_structure=OrderedDict(
|
||||
pk=IntField(),
|
||||
username=StrField(),
|
||||
email=StrField(),
|
||||
),
|
||||
),
|
||||
JSONSearchField(
|
||||
Event,
|
||||
"brand",
|
||||
fixed_structure=OrderedDict(
|
||||
pk=StrField(),
|
||||
app=StrField(),
|
||||
name=StrField(),
|
||||
model_name=StrField(),
|
||||
),
|
||||
),
|
||||
JSONSearchField(
|
||||
Event,
|
||||
"context",
|
||||
fixed_structure=OrderedDict(
|
||||
http_request=JSONSearchField(
|
||||
Event,
|
||||
"context_http_request",
|
||||
fixed_structure=OrderedDict(
|
||||
args=JSONSearchField(Event, "context_http_request_args"),
|
||||
path=StrField(),
|
||||
method=StrField(),
|
||||
request_id=StrField(),
|
||||
user_agent=StrField(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
JSONSearchField(Event, "user", suggest_nested=False),
|
||||
JSONSearchField(Event, "brand", suggest_nested=False),
|
||||
JSONSearchField(Event, "context", suggest_nested=False),
|
||||
DateTimeField(Event, "created", suggest_options=True),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from django.utils.timezone import now
|
||||
from rest_framework.fields import CharField, ChoiceField, DateTimeField, DictField
|
||||
from structlog import configure, get_config
|
||||
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter, get_logger
|
||||
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter
|
||||
from structlog.testing import LogCapture
|
||||
from structlog.types import EventDict
|
||||
|
||||
@@ -36,9 +36,6 @@ class LogEvent:
|
||||
event, log_level, item.pop("logger"), timestamp, attributes=sanitize_dict(item)
|
||||
)
|
||||
|
||||
def log(self):
|
||||
get_logger(self.logger).log(NAME_TO_LEVEL[self.log_level], self.event, **self.attributes)
|
||||
|
||||
|
||||
class LogEventSerializer(PassiveSerializer):
|
||||
"""Single log message with all context logged."""
|
||||
|
||||
@@ -19,7 +19,6 @@ from authentik.blueprints.v1.importer import excluded_models
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.models import Event, EventAction, Notification
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.lib.models import InternallyManagedMixin
|
||||
from authentik.lib.sentry import should_ignore_exception
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.stages.authenticator_static.models import StaticToken
|
||||
@@ -41,7 +40,7 @@ _CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", de
|
||||
|
||||
def should_log_model(model: Model) -> bool:
|
||||
"""Return true if operation on `model` should be logged"""
|
||||
return model.__class__ not in IGNORED_MODELS and not isinstance(model, InternallyManagedMixin)
|
||||
return model.__class__ not in IGNORED_MODELS
|
||||
|
||||
|
||||
def should_log_m2m(model: Model) -> bool:
|
||||
|
||||
@@ -8,8 +8,6 @@ from inspect import currentframe
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -43,7 +41,6 @@ from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.root.ws.consumer import build_user_group
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tasks.models import TasksModel
|
||||
@@ -364,15 +361,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
notification=notification,
|
||||
)
|
||||
notification.save()
|
||||
layer = get_channel_layer()
|
||||
async_to_sync(layer.group_send)(
|
||||
build_user_group(notification.user),
|
||||
{
|
||||
"type": "event.notification",
|
||||
"id": str(notification.pk),
|
||||
"data": notification.serializer(notification).data,
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
def send_webhook(self, notification: "Notification") -> list[str]:
|
||||
|
||||
@@ -20,7 +20,7 @@ from authentik.core.models import Token
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.challenge import FlowLayout
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InheritanceForeignKey, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
|
||||
@@ -301,7 +301,7 @@ class FriendlyNamedStage(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class FlowToken(InternallyManagedMixin, Token):
|
||||
class FlowToken(Token):
|
||||
"""Subclass of a standard Token, stores the currently active flow plan upon creation.
|
||||
Can be used to later resume a flow."""
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ class ChallengeStageView(StageView):
|
||||
"f(ch): invalid challenge response",
|
||||
errors=challenge_response.errors,
|
||||
)
|
||||
return HttpChallengeResponse(challenge_response)
|
||||
return HttpChallengeResponse(challenge_response, status=400)
|
||||
|
||||
|
||||
class AccessDeniedStage(ChallengeStageView):
|
||||
|
||||
@@ -147,7 +147,7 @@ class FlowExecutorView(APIView):
|
||||
token.delete()
|
||||
if not isinstance(plan, FlowPlan):
|
||||
return None
|
||||
if existing_plan := self.request.session.get(SESSION_KEY_PLAN):
|
||||
if existing_plan := self.request.session[SESSION_KEY_PLAN]:
|
||||
plan.context.update(existing_plan.context)
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
||||
@@ -258,6 +258,11 @@ class FlowExecutorView(APIView):
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
400: PolymorphicProxySerializer(
|
||||
component_name="ChallengeTypes",
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
},
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[
|
||||
@@ -305,6 +310,11 @@ class FlowExecutorView(APIView):
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
400: PolymorphicProxySerializer(
|
||||
component_name="ChallengeTypes",
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
},
|
||||
request=PolymorphicProxySerializer(
|
||||
component_name="FlowChallengeResponse",
|
||||
|
||||
@@ -111,7 +111,6 @@ def get_logger_config():
|
||||
"hpack": "WARNING",
|
||||
"httpx": "WARNING",
|
||||
"azure": "WARNING",
|
||||
"httpcore": "WARNING",
|
||||
}
|
||||
for handler_name, level in handler_level_map.items():
|
||||
base_config["loggers"][handler_name] = {
|
||||
|
||||
@@ -64,10 +64,6 @@ class DeprecatedMixin:
|
||||
"""Mixin for classes that are deprecated"""
|
||||
|
||||
|
||||
class InternallyManagedMixin:
|
||||
"""Mixin for models that should _not_ be manageable via blueprint."""
|
||||
|
||||
|
||||
class DomainlessURLValidator(URLValidator):
|
||||
"""Subclass of URLValidator which doesn't check the domain
|
||||
(to allow hostnames without domain)"""
|
||||
|
||||
@@ -84,7 +84,7 @@ class OutgoingSyncProvider(ScheduledModel, Model):
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_dispatch(self) -> None:
|
||||
for schedule in self.schedules.all():
|
||||
for schedule in self.schedules:
|
||||
schedule.send()
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"""authentik database utilities"""
|
||||
|
||||
import gc
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.db import reset_queries
|
||||
from django.db.models import Model, QuerySet
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -> Generator[T]:
|
||||
def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
|
||||
if not queryset.exists():
|
||||
return []
|
||||
|
||||
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
|
||||
def get_chunks(qs: QuerySet):
|
||||
qs = qs.order_by("pk")
|
||||
pks = qs.values_list("pk", flat=True)
|
||||
start_pk = pks[0]
|
||||
|
||||
@@ -9,7 +9,6 @@ from rest_framework.serializers import BaseSerializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.v1.importer import is_model_allowed
|
||||
from authentik.blueprints.v1.meta.registry import BaseMetaModel
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.policies.models import Policy
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
@@ -32,7 +31,7 @@ def model_choices() -> list[tuple[str, str]]:
|
||||
Returns a list of tuples containing (dotted.model.path, name)"""
|
||||
choices = []
|
||||
for model in apps.get_models():
|
||||
if not is_model_allowed(model) or issubclass(model, BaseMetaModel):
|
||||
if not is_model_allowed(model):
|
||||
continue
|
||||
name = f"{model._meta.app_label}.{model._meta.model_name}"
|
||||
choices.append((name, model._meta.verbose_name))
|
||||
|
||||
@@ -14,7 +14,7 @@ from structlog import get_logger
|
||||
|
||||
from authentik.core.models import ExpiringModel
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.policies.models import Policy
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
@@ -69,7 +69,7 @@ class ReputationPolicy(Policy):
|
||||
verbose_name_plural = _("Reputation Policies")
|
||||
|
||||
|
||||
class Reputation(InternallyManagedMixin, ExpiringModel, SerializerModel):
|
||||
class Reputation(ExpiringModel, SerializerModel):
|
||||
"""Reputation for user and or IP."""
|
||||
|
||||
objects = PostgresManager()
|
||||
|
||||
@@ -42,7 +42,7 @@ from authentik.core.models import (
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.providers.oauth2.constants import SubModes
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
@@ -462,7 +462,7 @@ class BaseGrantModel(models.Model):
|
||||
self._scope = " ".join(value)
|
||||
|
||||
|
||||
class AuthorizationCode(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Authorization Code"""
|
||||
|
||||
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
|
||||
@@ -497,7 +497,7 @@ class AuthorizationCode(InternallyManagedMixin, SerializerModel, ExpiringModel,
|
||||
)
|
||||
|
||||
|
||||
class AccessToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 access token, non-opaque using a JWT as identifier"""
|
||||
|
||||
token = models.TextField()
|
||||
@@ -545,7 +545,7 @@ class AccessToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGr
|
||||
return TokenModelSerializer
|
||||
|
||||
|
||||
class RefreshToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Refresh Token, opaque"""
|
||||
|
||||
token = models.TextField(default=generate_client_secret)
|
||||
@@ -585,7 +585,7 @@ class RefreshToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseG
|
||||
return TokenModelSerializer
|
||||
|
||||
|
||||
class DeviceToken(InternallyManagedMixin, ExpiringModel):
|
||||
class DeviceToken(ExpiringModel):
|
||||
"""Temporary device token for OAuth device flow"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
|
||||
@@ -13,7 +13,7 @@ from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import ExpiringModel
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
@@ -27,7 +27,7 @@ SCOPE_AK_PROXY = "ak_proxy"
|
||||
OUTPOST_CALLBACK_SIGNATURE = "X-authentik-auth-callback"
|
||||
|
||||
|
||||
class ProxySession(InternallyManagedMixin, ExpiringModel):
|
||||
class ProxySession(ExpiringModel):
|
||||
"""Session storage for proxyv2 outposts using PostgreSQL"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
@@ -15,7 +15,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User, default_token_key
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
@@ -145,7 +145,7 @@ class RACPropertyMapping(PropertyMapping):
|
||||
verbose_name_plural = _("RAC Provider Property Mappings")
|
||||
|
||||
|
||||
class ConnectionToken(InternallyManagedMixin, ExpiringModel):
|
||||
class ConnectionToken(ExpiringModel):
|
||||
"""Token for a single connection to a specified endpoint"""
|
||||
|
||||
connection_token_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
@@ -18,7 +18,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
@@ -303,7 +303,7 @@ class SAMLProviderImportModel(CreatableType, Provider):
|
||||
verbose_name_plural = _("SAML Providers from Metadata")
|
||||
|
||||
|
||||
class SAMLSession(InternallyManagedMixin, SerializerModel, ExpiringModel):
|
||||
class SAMLSession(SerializerModel, ExpiringModel):
|
||||
"""Track active SAML sessions for Single Logout support"""
|
||||
|
||||
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
@@ -13,7 +13,7 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import BackchannelProvider, Group, PropertyMapping, User, UserTypes
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
@@ -22,7 +22,7 @@ from authentik.providers.scim.clients.auth import SCIMTokenAuth
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class SCIMProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMProviderUser(SerializerModel):
|
||||
"""Mapping of a user and provider to a SCIM user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@@ -44,7 +44,7 @@ class SCIMProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"SCIM Provider User {self.user_id} to {self.provider_id}"
|
||||
|
||||
|
||||
class SCIMProviderGroup(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMProviderGroup(SerializerModel):
|
||||
"""Mapping of a group and provider to a SCIM user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -50,7 +50,7 @@ def get_user(scope):
|
||||
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
|
||||
)
|
||||
user = None
|
||||
if (authenticated_session := scope["session"].get("authenticatedsession", None)) is not None:
|
||||
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
|
||||
user = authenticated_session.user
|
||||
return user or AnonymousUser()
|
||||
|
||||
|
||||
@@ -96,9 +96,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
def add_arguments(cls, parser: ArgumentParser):
|
||||
"""Add more pytest-specific arguments"""
|
||||
DiscoverRunner.add_arguments(parser)
|
||||
default_seed = None
|
||||
if seed := os.getenv("CI_TEST_SEED"):
|
||||
default_seed = int(seed)
|
||||
parser.add_argument(
|
||||
"--randomly-seed",
|
||||
type=int,
|
||||
@@ -106,7 +103,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
"to reuse the seed from the previous run."
|
||||
"Default behaviour: use random.Random().getrandbits(32), so the seed is"
|
||||
"different on each run.",
|
||||
default=default_seed,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-capture",
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.routing import URLRouter
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.http import HttpRequest
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationTransport,
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root import websocket
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.user_login.stage import COOKIE_NAME_KNOWN_DEVICE
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
class TestClientWS(TransactionTestCase):
|
||||
|
||||
def setUp(self):
|
||||
tenant = get_current_tenant()
|
||||
tenant.flags[RefreshOtherFlowsAfterAuthentication().key] = True
|
||||
tenant.save()
|
||||
self.user = create_test_user()
|
||||
|
||||
async def _alogin_cookie(self, user, **kwargs):
|
||||
"""Similar to `client.aforce_login` but allow setting of cookies"""
|
||||
from django.contrib.auth import alogin
|
||||
|
||||
# Create a fake request to store login details.
|
||||
request = HttpRequest()
|
||||
session = await self.client.asession()
|
||||
request.session = session
|
||||
request.COOKIES.update(kwargs)
|
||||
|
||||
await alogin(request, user, BACKEND_INBUILT)
|
||||
# Save the session values.
|
||||
await request.session.asave()
|
||||
self.client._set_login_cookies(request)
|
||||
|
||||
async def test_auth_blank(self):
|
||||
dev_id = generate_id()
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns),
|
||||
"/ws/client/",
|
||||
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
|
||||
|
||||
await communicator.receive_nothing()
|
||||
await communicator.receive_json_from()
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_tab_refresh(self):
|
||||
dev_id = generate_id()
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns),
|
||||
"/ws/client/",
|
||||
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
with patch("authentik.flows.apps.RefreshOtherFlowsAfterAuthentication.get") as flag:
|
||||
flag.return_value = True
|
||||
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
|
||||
|
||||
evt = await communicator.receive_json_from()
|
||||
self.assertEqual(
|
||||
evt, {"message_type": "session.authenticated", "type": "event.session.authenticated"}
|
||||
)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_notification(self):
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
|
||||
)
|
||||
communicator.scope["user"] = self.user
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
transport = await NotificationTransport.objects.acreate(
|
||||
name=generate_id(), mode=TransportMode.LOCAL
|
||||
)
|
||||
event = await sync_to_async(Event.new)(EventAction.LOGIN)
|
||||
event.set_user(self.user)
|
||||
await event.asave()
|
||||
notification = Notification(
|
||||
user=self.user,
|
||||
body="foo",
|
||||
event=event,
|
||||
hyperlink="goauthentik.io",
|
||||
hyperlink_label="a link",
|
||||
)
|
||||
await sync_to_async(transport.send_local)(notification)
|
||||
|
||||
evt = await communicator.receive_json_from(timeout=5)
|
||||
self.assertEqual(evt["message_type"], "notification.new")
|
||||
self.assertEqual(evt["id"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["pk"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["body"], "foo")
|
||||
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
|
||||
|
||||
await communicator.disconnect()
|
||||
@@ -7,7 +7,6 @@ from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.root.ws.storage import CACHE_PREFIX
|
||||
|
||||
|
||||
@@ -17,34 +16,24 @@ def build_session_group(session_key: str):
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_device_group(device_id: str):
|
||||
def build_device_group(session_key: str):
|
||||
return sha256(
|
||||
f"{connection.schema_name}/group_client_device_{str(device_id)}".encode()
|
||||
f"{connection.schema_name}/group_client_device_{str(session_key)}".encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_user_group(user: User):
|
||||
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
|
||||
|
||||
|
||||
class MessageConsumer(JsonWebsocketConsumer):
|
||||
"""Consumer which sends django.contrib.messages Messages over WS.
|
||||
channel_name is saved into cache with user_id, and when a add_message is called"""
|
||||
|
||||
session_key: str
|
||||
device_cookie: str | None = None
|
||||
user: User | None = None
|
||||
|
||||
def connect(self):
|
||||
self.accept()
|
||||
self.session_key = self.scope["session"].session_key
|
||||
if self.session_key:
|
||||
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
|
||||
if user := self.scope.get("user"):
|
||||
if user.is_authenticated:
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
build_user_group(user), self.channel_name
|
||||
)
|
||||
if device_cookie := self.scope["cookies"].get("authentik_device", None):
|
||||
self.device_cookie = device_cookie
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
@@ -58,10 +47,6 @@ class MessageConsumer(JsonWebsocketConsumer):
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_device_group(self.device_cookie), self.channel_name
|
||||
)
|
||||
if self.user:
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_user_group(self.user), self.channel_name
|
||||
)
|
||||
|
||||
def event_message(self, event: dict):
|
||||
"""Event handler which is called by Messages Storage backend"""
|
||||
@@ -69,8 +54,4 @@ class MessageConsumer(JsonWebsocketConsumer):
|
||||
|
||||
def event_session_authenticated(self, event: dict):
|
||||
"""Event handler post user authentication"""
|
||||
self.send_json({"message_type": "session.authenticated", **event})
|
||||
|
||||
def event_notification(self, event: dict):
|
||||
"""Event handler for new notifications"""
|
||||
self.send_json({"message_type": "notification.new", **event})
|
||||
self.send_json({"message_type": "session.authenticated"})
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
|
||||
from authentik.core.models import Group, PropertyMapping, Source, Token, User
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
|
||||
class SCIMSource(Source):
|
||||
@@ -101,7 +101,7 @@ class SCIMSourcePropertyMapping(PropertyMapping):
|
||||
verbose_name_plural = _("SCIM Source Property Mappings")
|
||||
|
||||
|
||||
class SCIMSourceUser(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMSourceUser(SerializerModel):
|
||||
"""Mapping of a user and source to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True, default=uuid4)
|
||||
@@ -127,7 +127,7 @@ class SCIMSourceUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"SCIM User {self.user_id} to {self.source_id}"
|
||||
|
||||
|
||||
class SCIMSourceGroup(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMSourceGroup(SerializerModel):
|
||||
"""Mapping of a group and source to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True, default=uuid4)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.stages.authenticator.models import Device
|
||||
|
||||
UNKNOWN_DEVICE_TYPE_AAGUID = "00000000-0000-0000-0000-000000000000"
|
||||
@@ -164,7 +164,7 @@ class WebAuthnDevice(SerializerModel, Device):
|
||||
verbose_name_plural = _("WebAuthn Devices")
|
||||
|
||||
|
||||
class WebAuthnDeviceType(InternallyManagedMixin, SerializerModel):
|
||||
class WebAuthnDeviceType(SerializerModel):
|
||||
"""WebAuthn device type, used to restrict which device types are allowed"""
|
||||
|
||||
aaguid = models.UUIDField(primary_key=True, unique=True)
|
||||
|
||||
@@ -7,7 +7,7 @@ from rest_framework.serializers import BaseSerializer, Serializer
|
||||
|
||||
from authentik.core.models import Application, ExpiringModel, User
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class ConsentStage(Stage):
|
||||
verbose_name_plural = _("Consent Stages")
|
||||
|
||||
|
||||
class UserConsent(InternallyManagedMixin, SerializerModel, ExpiringModel):
|
||||
class UserConsent(SerializerModel, ExpiringModel):
|
||||
"""Consent given by a user for an application"""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Identification stage logic"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse
|
||||
@@ -160,8 +161,8 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
||||
op="authentik.stages.identification.validate_invalid_wait",
|
||||
name="Sleep random time on invalid user identifier",
|
||||
):
|
||||
# hash a random password on invalid identifier, same as with a valid identifier
|
||||
make_password(make_password(None))
|
||||
# Sleep a random time (between 90 and 210ms) to "prevent" user enumeration attacks
|
||||
sleep(0.030 * SystemRandom().randint(3, 7))
|
||||
# Log in a similar format to Event.new(), but we don't want to create an event here
|
||||
# as this stage is mostly used by unauthenticated users with very high rate limits
|
||||
self.stage.logger.info(
|
||||
|
||||
@@ -10,7 +10,7 @@ from django_dramatiq_postgres.models import TaskBase, TaskState
|
||||
|
||||
from authentik.events.logs import LogEvent
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -30,7 +30,7 @@ class TaskStatus(models.TextChoices):
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class Task(InternallyManagedMixin, SerializerModel, TaskBase):
|
||||
class Task(SerializerModel, TaskBase):
|
||||
tenant = models.ForeignKey(
|
||||
Tenant,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -144,7 +144,7 @@ class Task(InternallyManagedMixin, SerializerModel, TaskBase):
|
||||
self.log(self.uid, TaskStatus.ERROR, message, **attributes)
|
||||
|
||||
|
||||
class TaskLog(InternallyManagedMixin, models.Model):
|
||||
class TaskLog(models.Model):
|
||||
id = models.UUIDField(default=uuid4, primary_key=True, editable=False)
|
||||
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="tasklogs")
|
||||
|
||||
@@ -17,7 +17,7 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -41,7 +41,7 @@ def _validate_schema_name(name):
|
||||
)
|
||||
|
||||
|
||||
class Tenant(InternallyManagedMixin, TenantMixin, SerializerModel):
|
||||
class Tenant(TenantMixin, SerializerModel):
|
||||
"""Tenant"""
|
||||
|
||||
tenant_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -6276,11 +6276,6 @@
|
||||
],
|
||||
"format": "date-time",
|
||||
"title": "Expires"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Key"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
61
flake.lock
generated
61
flake.lock
generated
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"futils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"futils": "futils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
58
flake.nix
58
flake.nix
@@ -1,58 +0,0 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
futils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
futils,
|
||||
} @ inputs: let
|
||||
inherit (nixpkgs) lib;
|
||||
inherit (futils.lib) eachDefaultSystem defaultSystems;
|
||||
|
||||
nixpkgsFor = lib.genAttrs defaultSystems (system:
|
||||
import nixpkgs {
|
||||
inherit system;
|
||||
});
|
||||
in
|
||||
eachDefaultSystem (system: let
|
||||
pkgs = nixpkgsFor.${system};
|
||||
in {
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
clang
|
||||
cmake
|
||||
docker-compose
|
||||
gettext
|
||||
git
|
||||
glibc
|
||||
gnumake
|
||||
go
|
||||
golangci-lint
|
||||
krb5.dev
|
||||
krb5.out
|
||||
libclang
|
||||
libev
|
||||
libgcc
|
||||
libtool
|
||||
libunwind
|
||||
libuv
|
||||
libxml2
|
||||
libxslt
|
||||
lz4
|
||||
nodejs_24
|
||||
openssl
|
||||
pkg-config
|
||||
postgresql
|
||||
postgresql.pg_config
|
||||
python313
|
||||
sccache
|
||||
uv
|
||||
xmlsec
|
||||
zlib
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
8
go.mod
8
go.mod
@@ -1,6 +1,8 @@
|
||||
module goauthentik.io
|
||||
|
||||
go 1.25.5
|
||||
go 1.24.3
|
||||
|
||||
toolchain go1.24.6
|
||||
|
||||
require (
|
||||
beryju.io/ldap v0.1.0
|
||||
@@ -19,7 +21,7 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/grafana/pyroscope-go v1.2.7
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
@@ -30,7 +32,7 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2026020.6
|
||||
goauthentik.io/api/v3 v3.2026020.3
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
|
||||
8
go.sum
8
go.sum
@@ -117,8 +117,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -214,8 +214,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
goauthentik.io/api/v3 v3.2026020.6 h1:ww545OfZAS0OayLkMQGheR3AsgQ2rc61vhRwSo9dBco=
|
||||
goauthentik.io/api/v3 v3.2026020.6/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
goauthentik.io/api/v3 v3.2026020.3 h1:CKtPyAQToPT2yF5odTTc+IfPLhYeVX9FbLMeVnFgZps=
|
||||
goauthentik.io/api/v3 v3.2026020.3/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"slices"
|
||||
)
|
||||
import "crypto/tls"
|
||||
|
||||
func GetTLSConfig() *tls.Config {
|
||||
// Based on
|
||||
// https://ssl-config.mozilla.org/#server=go&version=1.25&config=intermediate&guideline=5.7
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{
|
||||
tls.X25519,
|
||||
tls.CurveP256,
|
||||
tls.CurveP384,
|
||||
},
|
||||
PreferServerCipherSuites: true,
|
||||
CipherSuites: []uint16{},
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
excludedCiphers := []uint16{
|
||||
// ChaCha20 is not FIPS validated
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
|
||||
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
|
||||
insecureCiphersIds := []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
|
||||
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
|
||||
}
|
||||
|
||||
defaultSecureCiphers := []uint16{}
|
||||
for _, cs := range tls.CipherSuites() {
|
||||
if slices.Contains(excludedCiphers, cs.ID) {
|
||||
continue
|
||||
for _, icsId := range insecureCiphersIds {
|
||||
if cs.ID != icsId {
|
||||
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
|
||||
}
|
||||
}
|
||||
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
|
||||
}
|
||||
tlsConfig.CipherSuites = defaultSecureCiphers
|
||||
return tlsConfig
|
||||
|
||||
@@ -73,7 +73,6 @@ def release_lock(conn: Connection, cursor: Cursor):
|
||||
|
||||
|
||||
def run_migrations():
|
||||
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
|
||||
conn = connect(
|
||||
dbname=CONFIG.get("postgresql.name"),
|
||||
user=CONFIG.get("postgresql.user"),
|
||||
@@ -84,7 +83,6 @@ def run_migrations():
|
||||
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
|
||||
sslcert=CONFIG.get("postgresql.sslcert"),
|
||||
sslkey=CONFIG.get("postgresql.sslkey"),
|
||||
**conn_opts,
|
||||
)
|
||||
curr = conn.cursor()
|
||||
try:
|
||||
|
||||
@@ -18,7 +18,6 @@ def check_postgres():
|
||||
if attempt >= CHECK_THRESHOLD:
|
||||
sysexit(1)
|
||||
try:
|
||||
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
|
||||
conn = connect(
|
||||
dbname=CONFIG.refresh("postgresql.name"),
|
||||
user=CONFIG.refresh("postgresql.user"),
|
||||
@@ -29,7 +28,6 @@ def check_postgres():
|
||||
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
|
||||
sslcert=CONFIG.get("postgresql.sslcert"),
|
||||
sslkey=CONFIG.get("postgresql.sslkey"),
|
||||
**conn_opts,
|
||||
)
|
||||
conn.cursor()
|
||||
break
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-28 00:13+0000\n"
|
||||
"POT-Creation-Date: 2025-12-17 00:10+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -712,10 +712,6 @@ msgstr ""
|
||||
msgid "Enterprise is required to create/update this object."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/api.py
|
||||
msgid "Enterprise is required to use this endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/models.py
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"""Convenient shortcuts to manage or check object permissions."""
|
||||
|
||||
from functools import lru_cache
|
||||
from functools import lru_cache, partial
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import connection
|
||||
from django.db.models import (
|
||||
AutoField,
|
||||
BigIntegerField,
|
||||
CharField,
|
||||
Count,
|
||||
ForeignKey,
|
||||
IntegerField,
|
||||
Model,
|
||||
PositiveIntegerField,
|
||||
PositiveSmallIntegerField,
|
||||
QuerySet,
|
||||
SmallIntegerField,
|
||||
UUIDField,
|
||||
)
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.expressions import Value
|
||||
from django.db.models.functions import Cast, Replace
|
||||
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.ctypes import get_content_type
|
||||
@@ -286,33 +295,42 @@ def get_objects_for_user( # noqa: PLR0912 PLR0915
|
||||
.filter(object_pk_count__gte=len(codenames))
|
||||
)
|
||||
|
||||
# pk is either UUID or an integer type, while object_pk is a varchar
|
||||
# object_pk is a varchar, while the queryset's pk is probably an integer or a uuid, so we cast
|
||||
handle_pk_field = _handle_pk_field(queryset)
|
||||
if handle_pk_field is not None:
|
||||
perms_queryset = perms_queryset.annotate(obj_pk=handle_pk_field(expression=pk_field))
|
||||
pk_field = "obj_pk"
|
||||
|
||||
return queryset.filter(pk__in=perms_queryset.values_list(pk_field, flat=True))
|
||||
|
||||
|
||||
def _handle_pk_field(queryset):
|
||||
pk = queryset.model._meta.pk
|
||||
|
||||
def _cast_type(pk):
|
||||
if isinstance(pk, ForeignKey):
|
||||
return _cast_type(pk.target_field)
|
||||
if isinstance(pk, UUIDField):
|
||||
return "uuid"
|
||||
return "bigint"
|
||||
if isinstance(pk, ForeignKey):
|
||||
return _handle_pk_field(pk.target_field)
|
||||
|
||||
cast_type = _cast_type(pk)
|
||||
if isinstance( # noqa: UP038
|
||||
pk,
|
||||
(
|
||||
IntegerField,
|
||||
AutoField,
|
||||
BigIntegerField,
|
||||
PositiveIntegerField,
|
||||
PositiveSmallIntegerField,
|
||||
SmallIntegerField,
|
||||
),
|
||||
):
|
||||
return partial(Cast, output_field=BigIntegerField())
|
||||
|
||||
perms_queryset = perms_queryset.values_list(pk_field, flat=True)
|
||||
# The raw subquery is done to ensure that casting only takes place after the WHERE clause of
|
||||
# `perms_queryset` is ran. Otherwise, the query planner may decide to cast every `object_pk`,
|
||||
# which breaks (for example) if it tries to cast an integer to a UUID. In such a case, the WHERE
|
||||
# of `perms_queryset` will remove any integer.
|
||||
# However, the subquery might get optimized out by the query planner, which would cause the same
|
||||
# cast issue as before. To prevent the subquery from being collapsed in the query below, we add
|
||||
# OFFSET 0.
|
||||
perms_subquery_sql, perms_subquery_params = perms_queryset.query.sql_with_params()
|
||||
subquery = RawSQL(
|
||||
f"""
|
||||
SELECT ("permission_subquery"."{pk_field}")::{cast_type} as "object_pk"
|
||||
FROM ({perms_subquery_sql}) "permission_subquery"
|
||||
OFFSET 0
|
||||
""", # nosec
|
||||
perms_subquery_params,
|
||||
)
|
||||
return queryset.filter(pk__in=subquery)
|
||||
if isinstance(pk, UUIDField):
|
||||
if connection.features.has_native_uuid_field:
|
||||
return partial(Cast, output_field=UUIDField())
|
||||
return partial(
|
||||
Replace,
|
||||
text=Value("-"),
|
||||
replacement=Value(""),
|
||||
output_field=CharField(),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
6
packages/docusaurus-config/package-lock.json
generated
6
packages/docusaurus-config/package-lock.json
generated
@@ -16336,9 +16336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -284,7 +284,6 @@ module = [
|
||||
"lifecycle.*",
|
||||
"tests.e2e.*",
|
||||
"tests.integration.*",
|
||||
"tests.openid_conformance.*",
|
||||
]
|
||||
ignore_errors = true
|
||||
|
||||
|
||||
25
schema.yml
25
schema.yml
@@ -26211,7 +26211,7 @@ paths:
|
||||
tags:
|
||||
- stages
|
||||
security:
|
||||
- {}
|
||||
- authentik: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
@@ -34792,7 +34792,6 @@ components:
|
||||
CapabilitiesEnum:
|
||||
enum:
|
||||
- can_save_media
|
||||
- can_save_reports
|
||||
- can_geo_ip
|
||||
- can_asn
|
||||
- can_impersonate
|
||||
@@ -35388,14 +35387,10 @@ components:
|
||||
model:
|
||||
type: string
|
||||
readOnly: true
|
||||
verbose_name_plural:
|
||||
type: string
|
||||
readOnly: true
|
||||
required:
|
||||
- app_label
|
||||
- id
|
||||
- model
|
||||
- verbose_name_plural
|
||||
ContextualFlowInfo:
|
||||
type: object
|
||||
description: Contextual flow information for a challenge
|
||||
@@ -35743,7 +35738,7 @@ components:
|
||||
readOnly: true
|
||||
requested_by:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/PartialUser'
|
||||
- $ref: '#/components/schemas/RequestedBy'
|
||||
readOnly: true
|
||||
requested_on:
|
||||
type: string
|
||||
@@ -51257,6 +51252,22 @@ components:
|
||||
minimum: -2147483648
|
||||
required:
|
||||
- name
|
||||
RequestedBy:
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
readOnly: true
|
||||
title: ID
|
||||
username:
|
||||
type: string
|
||||
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
|
||||
only.
|
||||
pattern: ^[\w.@+-]+$
|
||||
maxLength: 150
|
||||
required:
|
||||
- pk
|
||||
- username
|
||||
ResidentKeyRequirementEnum:
|
||||
enum:
|
||||
- discouraged
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user