Compare commits

..

5 Commits

Author SHA1 Message Date
8543e140ef stages/consent: fix error when requests with identical empty permissions
closes #3280

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-08-01 20:58:25 +02:00
e2cf578afd root: use updated schema
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-08-01 10:11:06 +02:00
0ce9fd9b2e web: fix updates
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-08-01 09:59:57 +02:00
d17ad65435 web: bump api client to match
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-07-31 23:54:29 +02:00
01529d3894 flows/stages/consent: fix for post requests (#3339)
add unique token to consent stage to ensure it is shown

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-07-31 23:53:06 +02:00
768 changed files with 32991 additions and 46162 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2022.9.0
current_version = 2022.7.3
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
@ -17,4 +17,4 @@ tag_name = version/{new_version}
[bumpversion:file:internal/constants/constants.go]
[bumpversion:file:web/src/common/constants.ts]
[bumpversion:file:web/src/constants.ts]

View File

@ -1,59 +0,0 @@
name: 'Comment usage instructions on PRs'
description: 'Comment usage instructions on PRs'
inputs:
tag:
description: "Image tag to pull"
required: true
runs:
using: "composite"
steps:
- name: Generate config
id: ev
uses: ./.github/actions/docker-push-variables
- name: Find Comment
uses: peter-evans/find-comment@v2
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: authentik PR Installation instructions
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v2
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
authentik PR Installation instructions
<details>
<summary>Instructions for docker-compose</summary>
Add the following block to your `.env` file:
```shell
AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
AUTHENTIK_TAG=${{ inputs.tag }}
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
```
Afterwards, run the upgrade commands from the latest release notes.
</details>
<details>
<summary>Instructions for Kubernetes</summary>
Add the following block to your `values.yml` file:
```yaml
authentik:
outposts:
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}
```
Afterwards, run the upgrade commands from the latest release notes.
</details>
edit-mode: replace

View File

@ -27,7 +27,7 @@ runs:
docker-compose -f .github/actions/setup/docker-compose.yml up -d
poetry env use python3.10
poetry install
cd web && npm ci
npm install -g pyright@1.1.136
- name: Generate config
shell: poetry run python {0}
run: |

View File

@ -1,4 +1,3 @@
keypair
keypairs
hass
warmup

View File

@ -96,8 +96,6 @@ jobs:
testspace [unittest]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
with:
flags: unit
test-integration:
runs-on: ubuntu-latest
steps:
@ -119,8 +117,6 @@ jobs:
testspace [integration]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
with:
flags: integration
test-e2e-provider:
runs-on: ubuntu-latest
steps:
@ -143,7 +139,7 @@ jobs:
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
make -C .. gen-client-web
npm run build
- name: run e2e
run: |
@ -155,8 +151,6 @@ jobs:
testspace [e2e-provider]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
with:
flags: e2e
test-e2e-rest:
runs-on: ubuntu-latest
steps:
@ -179,7 +173,7 @@ jobs:
working-directory: web/
run: |
npm ci
make -C .. gen-client-ts
make -C .. gen-client-web
npm run build
- name: run e2e
run: |
@ -191,8 +185,6 @@ jobs:
testspace [e2e-rest]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
with:
flags: e2e
ci-core-mark:
needs:
- lint
@ -243,9 +235,3 @@ jobs:
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
platforms: ${{ matrix.arch }}
- name: Comment on PR
if: github.event_name == 'pull_request'
continue-on-error: true
uses: ./.github/actions/comment-pr-instructions
with:
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}

View File

@ -23,7 +23,7 @@ jobs:
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
run: make gen-client-web
- name: Eslint
working-directory: web/
run: npm run lint
@ -39,7 +39,7 @@ jobs:
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
run: make gen-client-web
- name: prettier
working-directory: web/
run: npm run prettier-check
@ -60,7 +60,7 @@ jobs:
cd node_modules/@goauthentik
ln -s ../../src/ web
- name: Generate API
run: make gen-client-ts
run: make gen-client-web
- name: lit-analyse
working-directory: web/
run: npm run lit-analyse
@ -86,7 +86,7 @@ jobs:
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
run: make gen-client-web
- name: build
working-directory: web/
run: npm run build

View File

@ -138,7 +138,7 @@ jobs:
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
docker-compose run -u root server test
sentry-release:
needs:
- build-server
@ -157,7 +157,6 @@ jobs:
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@v1
continue-on-error: true
if: ${{ github.event_name == 'release' }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@ -16,12 +16,15 @@ jobs:
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker buildx install
docker build -t testing:latest .
docker build \
--no-cache \
-t testing:latest \
-f Dockerfile .
echo "AUTHENTIK_IMAGE=testing" >> .env
echo "AUTHENTIK_TAG=latest" >> .env
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
docker-compose run -u root server test
- name: Extract version number
id: get_version
uses: actions/github-script@v6

View File

@ -10,12 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v3.4.1
with:
node-version: '16'
registry-url: 'https://registry.npmjs.org'
- name: Generate API Client
run: make gen-client-ts
run: make gen-client-web
- name: Publish package
working-directory: gen-ts-api/
run: |
@ -34,8 +35,8 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: update-web-api-client
commit-message: "web: bump API Client version"
title: "web: bump API Client version"
body: "web: bump API Client version"
commit-message: "web: Update Web API Client version"
title: "web: Update Web API Client version"
body: "web: Update Web API Client version"
delete-branch: true
signoff: true

12
.vscode/settings.json vendored
View File

@ -20,15 +20,11 @@
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"python.formatting.provider": "black",
"yaml.customTags": [
"!Find sequence",
"!KeyOf scalar"
],
"files.associations": {
"*.akflow": "json"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",
"typescript.tsdk": "./web/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"yaml.schemas": {
"./blueprints/schema.json": "blueprints/**/*.yaml"
}
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@ -2,7 +2,6 @@
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as website-builder
COPY ./website /work/website/
COPY ./blueprints /work/blueprints/
ENV NODE_ENV=production
WORKDIR /work/website
@ -19,7 +18,7 @@ WORKDIR /work/web
RUN npm ci && npm run build
# Stage 3: Poetry to requirements.txt export
FROM docker.io/python:3.10.7-slim-bullseye AS poetry-locker
FROM docker.io/python:3.10.5-slim-bullseye AS poetry-locker
WORKDIR /work
COPY ./pyproject.toml /work
@ -30,7 +29,7 @@ RUN pip install --no-cache-dir poetry && \
poetry export -f requirements.txt --dev --output requirements-dev.txt
# Stage 4: Build go proxy
FROM docker.io/golang:1.19.1-bullseye AS go-builder
FROM docker.io/golang:1.18.4-bullseye AS builder
WORKDIR /work
@ -46,7 +45,7 @@ COPY ./go.sum /work/go.sum
RUN go build -o /work/authentik ./cmd/server/main.go
# Stage 5: Run
FROM docker.io/python:3.10.7-slim-bullseye AS final-image
FROM docker.io/python:3.10.5-slim-bullseye
LABEL org.opencontainers.image.url https://goauthentik.io
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
@ -73,7 +72,7 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /certs /media /blueprints && \
mkdir -p /certs /media && \
mkdir -p /authentik/.ssh && \
chown authentik:authentik /certs /media /authentik/.ssh
@ -82,14 +81,13 @@ COPY ./pyproject.toml /
COPY ./xml /xml
COPY ./tests /tests
COPY ./manage.py /
COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY --from=go-builder /work/authentik /authentik-proxy
COPY --from=builder /work/authentik /authentik-proxy
COPY --from=web-builder /work/web/dist/ /web/dist/
COPY --from=web-builder /work/web/authentik/ /web/authentik/
COPY --from=website-builder /work/website/help/ /website/help/
USER 1000
USER authentik
ENV TMPDIR /dev/shm/
ENV PYTHONUNBUFFERED 1
@ -97,4 +95,4 @@ ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ]
ENTRYPOINT [ "/usr/local/bin/dumb-init", "--", "/lifecycle/ak" ]
ENTRYPOINT [ "/lifecycle/ak" ]

View File

@ -28,13 +28,13 @@ test-docker:
rm -f .env
test:
coverage run manage.py test --keepdb authentik
coverage run manage.py test authentik
coverage html
coverage report
lint-fix:
isort authentik tests scripts lifecycle
black authentik tests scripts lifecycle
isort authentik tests lifecycle
black authentik tests lifecycle
codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w \
authentik \
internal \
@ -49,50 +49,27 @@ lint:
bandit -r authentik tests lifecycle -x node_modules
golangci-lint run -v
migrate:
python -m lifecycle.migrate
run:
go run -v cmd/server/main.go
i18n-extract: i18n-extract-core web-extract
i18n-extract-core:
ak makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
#########################
## API Schema
#########################
./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
gen-build:
AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_DEBUG=true ak spectacular --file schema.yml
gen-diff:
git show $(shell git tag -l | tail -n 1):schema.yml > old_schema.yml
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-diff:2.0.1 \
--markdown /local/diff.md \
/local/old_schema.yml /local/schema.yml
rm old_schema.yml
AUTHENTIK_DEBUG=true ./manage.py spectacular --file schema.yml
gen-clean:
rm -rf web/api/src/
rm -rf api/
gen-client-ts:
gen-client-web:
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \
openapitools/openapi-generator-cli:v6.0.0 generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/gen-ts-api \
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION}
mkdir -p web/node_modules/@goauthentik/api
\cp -fv scripts/web_api_readme.md gen-ts-api/README.md
cd gen-ts-api && npm i
@ -106,7 +83,7 @@ gen-client-go:
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \
openapitools/openapi-generator-cli:v6.0.0 generate \
-i /local/schema.yml \
-g go \
-o /local/gen-go-api \
@ -114,10 +91,13 @@ gen-client-go:
go mod edit -replace goauthentik.io/api/v3=./gen-go-api
rm -rf config.yaml ./templates/
gen-dev-config:
python -m scripts.generate_config
gen: gen-build gen-clean gen-client-web
gen: gen-build gen-clean gen-client-ts
migrate:
python -m lifecycle.migrate
run:
go run -v cmd/server/main.go
#########################
## Web
@ -164,37 +144,28 @@ website-watch:
# These targets are use by GitHub actions to allow usage of matrix
# which makes the YAML File a lot smaller
PY_SOURCES=authentik tests lifecycle
ci--meta-debug:
python -V
node --version
ci-pylint: ci--meta-debug
pylint $(PY_SOURCES)
pylint authentik tests lifecycle
ci-black: ci--meta-debug
black --check $(PY_SOURCES)
black --check authentik tests lifecycle
ci-isort: ci--meta-debug
isort --check $(PY_SOURCES)
isort --check authentik tests lifecycle
ci-bandit: ci--meta-debug
bandit -r $(PY_SOURCES)
bandit -r authentik tests lifecycle
ci-pyright: ci--meta-debug
./web/node_modules/.bin/pyright $(PY_SOURCES)
pyright e2e lifecycle
ci-pending-migrations: ci--meta-debug
ak makemigrations --check
./manage.py makemigrations --check
install: web-install website-install
poetry install
dev-reset:
dropdb -U postgres -h localhost authentik
createdb -U postgres -h localhost authentik
redis-cli -n 0 flushall
redis-cli -n 1 flushall
redis-cli -n 2 flushall
redis-cli -n 3 flushall
make migrate

View File

@ -6,9 +6,9 @@
| Version | Supported |
| ---------- | ------------------ |
| 2022.5.x | :white_check_mark: |
| 2022.6.x | :white_check_mark: |
| 2022.7.x | :white_check_mark: |
| 2022.8.x | :white_check_mark: |
## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ
from typing import Optional
__version__ = "2022.9.0"
__version__ = "2022.7.3"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -18,13 +18,13 @@ class AppSerializer(PassiveSerializer):
class AppsViewSet(ViewSet):
"""Read-only view list all installed apps"""
"""Read-only view set list all installed apps"""
permission_classes = [IsAdminUser]
@extend_schema(responses={200: AppSerializer(many=True)})
def list(self, request: Request) -> Response:
"""Read-only view list all installed apps"""
"""List current messages and pass into Serializer"""
data = []
for app in sorted(get_apps(), key=lambda app: app.name):
data.append({"name": app.name, "label": app.verbose_name})

View File

@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.managed import MANAGED_OUTPOST
from authentik.outposts.models import Outpost

View File

@ -5,7 +5,7 @@ from django.contrib import messages
from django.http.response import Http404
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
from rest_framework.permissions import IsAdminUser
@ -58,15 +58,7 @@ class TaskViewSet(ViewSet):
responses={
200: TaskSerializer(many=False),
404: OpenApiResponse(description="Task not found"),
},
parameters=[
OpenApiParameter(
"id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
required=True,
),
],
}
)
# pylint: disable=invalid-name
def retrieve(self, request: Request, pk=None) -> Response:
@ -89,14 +81,6 @@ class TaskViewSet(ViewSet):
404: OpenApiResponse(description="Task not found"),
500: OpenApiResponse(description="Failed to retry task"),
},
parameters=[
OpenApiParameter(
"id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
required=True,
),
],
)
@action(detail=True, methods=["post"])
# pylint: disable=invalid-name

View File

@ -1,20 +1,19 @@
"""authentik admin app config"""
from prometheus_client import Gauge, Info
from importlib import import_module
from authentik.blueprints.apps import ManagedAppConfig
from django.apps import AppConfig
from prometheus_client import Gauge, Info
PROM_INFO = Info("authentik_version", "Currently running authentik version")
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
class AuthentikAdminConfig(ManagedAppConfig):
class AuthentikAdminConfig(AppConfig):
"""authentik admin app config"""
name = "authentik.admin"
label = "authentik_admin"
verbose_name = "authentik Admin"
default = True
def reconcile_load_admin_signals(self):
"""Load admin signals"""
self.import_module("authentik.admin.signals")
def ready(self):
import_module("authentik.admin.signals")

View File

@ -3,7 +3,6 @@ import re
from django.core.cache import cache
from django.core.validators import URLValidator
from django.db import DatabaseError, InternalError, ProgrammingError
from packaging.version import parse
from requests import RequestException
from structlog.stdlib import get_logger
@ -40,9 +39,7 @@ def _set_prom_info():
)
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
@CELERY_APP.task()
def clear_update_notifications():
"""Clear update notifications on startup if the notification was for the version
we're running now."""

View File

@ -5,10 +5,10 @@ from django.test import TestCase
from django.urls import reverse
from authentik import __version__
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Group, User
from authentik.core.tasks import clean_expired_models
from authentik.events.monitored_tasks import TaskResultStatus
from authentik.managed.tasks import managed_reconcile
class TestAdminAPI(TestCase):
@ -93,8 +93,9 @@ class TestAdminAPI(TestCase):
response = self.client.get(reverse("authentik_api:apps-list"))
self.assertEqual(response.status_code, 200)
@reconcile_app("authentik_outposts")
def test_system(self):
"""Test system API"""
# pyright: reportGeneralTypeIssues=false
managed_reconcile() # pylint: disable=no-value-for-parameter
response = self.client.get(reverse("authentik_api:admin_system"))
self.assertEqual(response.status_code, 200)

View File

@ -7,7 +7,7 @@ from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.middleware import KEY_AUTH_VIA, LOCAL
from authentik.core.models import Token, TokenIntents, User
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
@ -16,7 +16,7 @@ from authentik.providers.oauth2.models import RefreshToken
LOGGER = get_logger()
def validate_auth(header: bytes) -> Optional[str]:
def validate_auth(header: bytes) -> str:
"""Validate that the header is in a correct format,
returns type and credentials"""
auth_credentials = header.decode().strip()
@ -36,12 +36,14 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
auth_credentials = validate_auth(raw_header)
if not auth_credentials:
return None
if not hasattr(LOCAL, "authentik"):
LOCAL.authentik = {}
# first, check traditional tokens
key_token = Token.filter_not_expired(
key=auth_credentials, intent=TokenIntents.INTENT_API
).first()
if key_token:
CTX_AUTH_VIA.set("api_token")
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
return key_token.user
# then try to auth via JWT
jwt_token = RefreshToken.filter_not_expired(
@ -52,12 +54,12 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
# we want to check the parsed version too
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
raise AuthenticationFailed("Token invalid/expired")
CTX_AUTH_VIA.set("jwt")
LOCAL.authentik[KEY_AUTH_VIA] = "jwt"
return jwt_token.user
# then try to auth via secret key (for embedded outpost/etc)
user = token_secret_key(auth_credentials)
if user:
CTX_AUTH_VIA.set("secret_key")
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
return user
raise AuthenticationFailed("Token invalid/expired")
@ -65,7 +67,7 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
def token_secret_key(value: str) -> Optional[User]:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.managed import MANAGED_OUTPOST
if value != settings.SECRET_KEY:
return None

View File

@ -60,28 +60,8 @@ def postprocess_schema_responses(result, generator, **kwargs): # noqa: W0613
for path in result["paths"].values():
for method in path.values():
method["responses"].setdefault(
"400",
{
"content": {
"application/json": {
"schema": validation_error.ref,
}
},
"description": "",
},
)
method["responses"].setdefault(
"403",
{
"content": {
"application/json": {
"schema": generic_error.ref,
}
},
"description": "",
},
)
method["responses"].setdefault("400", validation_error.ref)
method["responses"].setdefault("403", generic_error.ref)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)

View File

@ -7,10 +7,10 @@ from guardian.shortcuts import get_anonymous_user
from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import bearer_auth
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.managed import OutpostManager
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
@ -42,11 +42,9 @@ class TestAPIAuth(TestCase):
def test_managed_outpost(self):
"""Test managed outpost"""
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_success(self):
"""Test managed outpost"""
OutpostManager().run()
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)

View File

@ -68,9 +68,10 @@ class ConfigView(APIView):
caps.append(Capabilities.CAN_IMPERSONATE)
return caps
def get_config(self) -> ConfigSerializer:
"""Get Config"""
return ConfigSerializer(
@extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Retrieve public configuration options"""
config = ConfigSerializer(
{
"error_reporting": {
"enabled": CONFIG.y("error_reporting.enabled"),
@ -85,8 +86,4 @@ class ConfigView(APIView):
"cache_timeout_reputation": int(CONFIG.y("redis.cache_timeout_reputation")),
}
)
@extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Retrieve public configuration options"""
return Response(self.get_config().data)
return Response(config.data)

View File

@ -12,10 +12,9 @@ from authentik.admin.api.version import VersionView
from authentik.admin.api.workers import WorkerView
from authentik.api.v3.config import ConfigView
from authentik.api.views import APIBrowserView
from authentik.blueprints.api import BlueprintInstanceViewSet
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.devices import DeviceViewSet
from authentik.core.api.groups import GroupViewSet
from authentik.core.api.propertymappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet
@ -132,8 +131,6 @@ router.register("events/notifications", NotificationViewSet)
router.register("events/transports", NotificationTransportViewSet)
router.register("events/rules", NotificationRuleViewSet)
router.register("managed/blueprints", BlueprintInstanceViewSet)
router.register("sources/all", SourceViewSet)
router.register("sources/user_connections/all", UserSourceConnectionViewSet)
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
@ -174,11 +171,6 @@ router.register("authenticators/sms", SMSDeviceViewSet)
router.register("authenticators/static", StaticDeviceViewSet)
router.register("authenticators/totp", TOTPDeviceViewSet)
router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
router.register(
"authenticators/admin/all",
AdminDeviceViewSet,
basename="admin-device",
)
router.register(
"authenticators/admin/duo",
DuoAdminDeviceViewSet,

View File

@ -1,109 +0,0 @@
"""Serializer mixin for managed models"""
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField, JSONField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
class ManagedSerializer:
"""Managed Serializer"""
managed = CharField(read_only=True, allow_null=True)
class MetadataSerializer(PassiveSerializer):
"""Serializer for blueprint metadata"""
name = CharField()
labels = JSONField()
class BlueprintInstanceSerializer(ModelSerializer):
"""Info about a single blueprint instance file"""
def validate_path(self, path: str) -> str:
"""Ensure the path specified is retrievable"""
try:
BlueprintInstance(path=path).retrieve()
except BlueprintRetrievalFailed as exc:
raise ValidationError(exc) from exc
return path
class Meta:
model = BlueprintInstance
fields = [
"pk",
"name",
"path",
"context",
"last_applied",
"last_applied_hash",
"status",
"enabled",
"managed_models",
"metadata",
]
extra_kwargs = {
"status": {"read_only": True},
"last_applied": {"read_only": True},
"last_applied_hash": {"read_only": True},
"managed_models": {"read_only": True},
"metadata": {"read_only": True},
}
class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
"""Blueprint instances"""
permission_classes = [IsAdminUser]
serializer_class = BlueprintInstanceSerializer
queryset = BlueprintInstance.objects.all()
search_fields = ["name", "path"]
filterset_fields = ["name", "path"]
@extend_schema(
responses={
200: ListSerializer(
child=inline_serializer(
"BlueprintFile",
fields={
"path": CharField(),
"last_m": DateTimeField(),
"hash": CharField(),
"meta": MetadataSerializer(required=False, read_only=True),
},
)
)
}
)
@action(detail=False, pagination_class=None, filter_backends=[])
def available(self, request: Request) -> Response:
"""Get blueprints"""
files: list[dict] = blueprints_find_dict.delay().get()
return Response(files)
@permission_required("authentik_blueprints.view_blueprintinstance")
@extend_schema(
request=None,
responses={
200: BlueprintInstanceSerializer(),
},
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def apply(self, request: Request, *args, **kwargs) -> Response:
"""Apply a blueprint"""
blueprint = self.get_object()
apply_blueprint.delay(str(blueprint.pk)).get()
return self.retrieve(request, *args, **kwargs)

View File

@ -1,66 +0,0 @@
"""authentik Blueprints app"""
from importlib import import_module
from inspect import ismethod
from django.apps import AppConfig
from django.db import DatabaseError, InternalError, ProgrammingError
from structlog.stdlib import BoundLogger, get_logger
class ManagedAppConfig(AppConfig):
"""Basic reconciliation logic for apps"""
_logger: BoundLogger
def __init__(self, app_name: str, *args, **kwargs) -> None:
super().__init__(app_name, *args, **kwargs)
self._logger = get_logger().bind(app_name=app_name)
def ready(self) -> None:
self.reconcile()
return super().ready()
def import_module(self, path: str):
"""Load module"""
import_module(path)
def reconcile(self) -> None:
"""reconcile ourselves"""
prefix = "reconcile_"
for meth_name in dir(self):
meth = getattr(self, meth_name)
if not ismethod(meth):
continue
if not meth_name.startswith(prefix):
continue
name = meth_name.replace(prefix, "")
try:
self._logger.debug("Starting reconciler", name=name)
meth()
self._logger.debug("Successfully reconciled", name=name)
except (DatabaseError, ProgrammingError, InternalError) as exc:
self._logger.debug("Failed to run reconcile", name=name, exc=exc)
class AuthentikBlueprintsConfig(ManagedAppConfig):
"""authentik Blueprints app"""
name = "authentik.blueprints"
label = "authentik_blueprints"
verbose_name = "authentik Blueprints"
default = True
def reconcile_load_blueprints_v1_tasks(self):
"""Load v1 tasks"""
self.import_module("authentik.blueprints.v1.tasks")
def reconcile_blueprints_discover(self):
"""Run blueprint discovery"""
from authentik.blueprints.v1.tasks import blueprints_discover
blueprints_discover.delay()
def import_models(self):
super().import_models()
self.import_module("authentik.blueprints.v1.meta.apply_blueprint")

View File

@ -1,31 +0,0 @@
"""Apply blueprint from commandline"""
from sys import exit as sys_exit
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
LOGGER = get_logger()
class Command(BaseCommand):
"""Apply blueprint from commandline"""
@no_translations
def handle(self, *args, **options):
"""Apply all blueprints in order, abort when one fails to import"""
for blueprint_path in options.get("blueprints", []):
content = BlueprintInstance(path=blueprint_path).retrieve()
importer = Importer(content)
valid, logs = importer.validate()
if not valid:
for log in logs:
getattr(LOGGER, log.pop("log_level"))(**log)
self.stderr.write("blueprint invalid")
sys_exit(1)
importer.apply()
def add_arguments(self, parser):
parser.add_argument("blueprints", nargs="+", type=str)

View File

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

View File

@ -1,36 +0,0 @@
"""Generate JSON Schema for blueprints"""
from json import dumps, loads
from pathlib import Path
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.meta.registry import registry
LOGGER = get_logger()
class Command(BaseCommand):
"""Generate JSON Schema for blueprints"""
schema: dict
@no_translations
def handle(self, *args, **options):
"""Generate JSON Schema for blueprints"""
path = Path(__file__).parent.joinpath("./schema_template.json")
with open(path, "r", encoding="utf-8") as _template_file:
self.schema = loads(_template_file.read())
self.set_model_allowed()
self.stdout.write(dumps(self.schema, indent=4))
def set_model_allowed(self):
"""Set model enum"""
model_names = []
for model in registry.get_models():
if not is_model_allowed(model):
continue
model_names.append(f"{model._meta.app_label}.{model._meta.model_name}")
model_names.sort()
self.schema["properties"]["entries"]["items"]["properties"]["model"]["enum"] = model_names

View File

@ -1,90 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "http://example.com/example.json",
"type": "object",
"title": "authentik Blueprint schema",
"default": {},
"required": [
"version",
"entries"
],
"properties": {
"version": {
"$id": "#/properties/version",
"type": "integer",
"title": "Blueprint version",
"default": 1
},
"metadata": {
"$id": "#/properties/metadata",
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
},
"labels": {
"type": "object"
}
}
},
"context": {
"$id": "#/properties/context",
"type": "object",
"additionalProperties": true
},
"entries": {
"type": "array",
"items": {
"$id": "#entry",
"type": "object",
"required": [
"model"
],
"properties": {
"model": {
"type": "string",
"enum": [
"placeholder"
]
},
"id": {
"type": "string"
},
"attrs": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Commonly available field, may not exist on all models"
}
},
"default": {},
"additionalProperties": true
},
"identifiers": {
"type": "object",
"default": {},
"properties": {
"pk": {
"description": "Commonly available field, may not exist on all models",
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"format": "uuid"
}
]
}
},
"additionalProperties": true
}
}
}
}
}
}

View File

@ -1,135 +0,0 @@
# Generated by Django 4.0.6 on 2022-07-31 17:35
import uuid
from glob import glob
from pathlib import Path
import django.contrib.postgres.fields
from dacite.core import from_dict
from django.apps.registry import Apps
from django.conf import settings
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from yaml import load
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM
from authentik.lib.config import CONFIG
def check_blueprint_v1_file(BlueprintInstance: type["BlueprintInstance"], path: Path):
"""Check if blueprint should be imported"""
from authentik.blueprints.models import BlueprintInstanceStatus
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
with open(path, "r", encoding="utf-8") as blueprint_file:
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
if not raw_blueprint:
return
metadata = raw_blueprint.get("metadata", None)
version = raw_blueprint.get("version", 1)
if version != 1:
return
blueprint_file.seek(0)
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
rel_path = path.relative_to(Path(CONFIG.y("blueprints_dir")))
meta = None
if metadata:
meta = from_dict(BlueprintMetadata, metadata)
if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false":
return
if not instance:
instance = BlueprintInstance(
name=meta.name if meta else str(rel_path),
path=str(rel_path),
context={},
status=BlueprintInstanceStatus.UNKNOWN,
enabled=True,
managed_models=[],
last_applied_hash="",
metadata=metadata,
)
instance.save()
def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
BlueprintInstance = apps.get_model("authentik_blueprints", "BlueprintInstance")
Flow = apps.get_model("authentik_flows", "Flow")
db_alias = schema_editor.connection.alias
for file in glob(f"{CONFIG.y('blueprints_dir')}/**/*.yaml", recursive=True):
check_blueprint_v1_file(BlueprintInstance, Path(file))
for blueprint in BlueprintInstance.objects.using(db_alias).all():
# If we already have flows (and we should always run before flow migrations)
# then this is an existing install and we want to disable all blueprints
if Flow.objects.using(db_alias).all().exists():
blueprint.enabled = False
# System blueprints are always enabled
if blueprint.metadata.get("labels", {}).get(LABEL_AUTHENTIK_SYSTEM, "").lower() == "true":
blueprint.enabled = True
blueprint.save()
class Migration(migrations.Migration):
initial = True
dependencies = [("authentik_flows", "0001_initial")]
operations = [
migrations.CreateModel(
name="BlueprintInstance",
fields=[
("created", models.DateTimeField(auto_now_add=True)),
("last_updated", models.DateTimeField(auto_now=True)),
(
"managed",
models.TextField(
default=None,
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
null=True,
unique=True,
verbose_name="Managed by authentik",
),
),
(
"instance_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("name", models.TextField()),
("metadata", models.JSONField(default=dict)),
("path", models.TextField()),
("context", models.JSONField(default=dict)),
("last_applied", models.DateTimeField(auto_now=True)),
("last_applied_hash", models.TextField()),
(
"status",
models.TextField(
choices=[
("successful", "Successful"),
("warning", "Warning"),
("error", "Error"),
("orphaned", "Orphaned"),
("unknown", "Unknown"),
],
default="unknown",
),
),
("enabled", models.BooleanField(default=True)),
(
"managed_models",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), default=list, size=None
),
),
],
options={
"verbose_name": "Blueprint Instance",
"verbose_name_plural": "Blueprint Instances",
"unique_together": {("name", "path")},
},
),
migrations.RunPython(migration_blueprint_import),
]

View File

@ -1,163 +0,0 @@
"""blueprint models"""
from pathlib import Path
from urllib.parse import urlparse
from uuid import uuid4
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.translation import gettext_lazy as _
from opencontainers.distribution.reggie import (
NewClient,
WithDebug,
WithDefaultName,
WithDigest,
WithReference,
WithUserAgent,
WithUsernamePassword,
)
from requests.exceptions import RequestException
from rest_framework.serializers import Serializer
from structlog import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.http import authentik_user_agent
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
LOGGER = get_logger()
class BlueprintRetrievalFailed(SentryIgnoredException):
"""Error raised when we're unable to fetch the blueprint contents, whether it be HTTP files
not being accessible or local files not being readable"""
class ManagedModel(models.Model):
"""Model which can be managed by authentik exclusively"""
managed = models.TextField(
default=None,
null=True,
verbose_name=_("Managed by authentik"),
help_text=_(
(
"Objects which are managed by authentik. These objects are created and updated "
"automatically. This is flag only indicates that an object can be overwritten by "
"migrations. You can still modify the objects via the API, but expect changes "
"to be overwritten in a later update."
)
),
unique=True,
)
class Meta:
abstract = True
class BlueprintInstanceStatus(models.TextChoices):
"""Instance status"""
SUCCESSFUL = "successful"
WARNING = "warning"
ERROR = "error"
ORPHANED = "orphaned"
UNKNOWN = "unknown"
class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
"""Instance of a single blueprint. Can be parameterized via context attribute when
blueprint in `path` has inputs."""
instance_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField()
metadata = models.JSONField(default=dict)
path = models.TextField()
context = models.JSONField(default=dict)
last_applied = models.DateTimeField(auto_now=True)
last_applied_hash = models.TextField()
status = models.TextField(
choices=BlueprintInstanceStatus.choices, default=BlueprintInstanceStatus.UNKNOWN
)
enabled = models.BooleanField(default=True)
managed_models = ArrayField(models.TextField(), default=list)
def retrieve_oci(self) -> str:
"""Get blueprint from an OCI registry"""
url = urlparse(self.path)
ref = "latest"
path = url.path[1:]
if ":" in url.path:
path, _, ref = path.partition(":")
client = NewClient(
f"{url.scheme}://{url.hostname}",
WithUserAgent(authentik_user_agent()),
WithUsernamePassword(url.username, url.password),
WithDefaultName(path),
WithDebug(True),
)
LOGGER.info("Fetching OCI manifests for blueprint", instance=self)
manifest_request = client.NewRequest(
"GET",
"/v2/<name>/manifests/<reference>",
WithReference(ref),
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
try:
manifest_response = client.Do(manifest_request)
manifest_response.raise_for_status()
except RequestException as exc:
raise BlueprintRetrievalFailed(exc) from exc
manifest = manifest_response.json()
if "errors" in manifest:
raise BlueprintRetrievalFailed(manifest["errors"])
blob = None
for layer in manifest.get("layers", []):
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
blob = layer.get("digest")
LOGGER.debug("Found layer with matching media type", instance=self, blob=blob)
if not blob:
raise BlueprintRetrievalFailed("Blob not found")
blob_request = client.NewRequest(
"GET",
"/v2/<name>/blobs/<digest>",
WithDigest(blob),
)
try:
blob_response = client.Do(blob_request)
blob_response.raise_for_status()
return blob_response.text
except RequestException as exc:
raise BlueprintRetrievalFailed(exc) from exc
def retrieve(self) -> str:
"""Retrieve blueprint contents"""
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
if full_path.exists():
LOGGER.debug("Blueprint path exists locally", instance=self)
with full_path.open("r", encoding="utf-8") as _file:
return _file.read()
return self.retrieve_oci()
@property
def serializer(self) -> Serializer:
from authentik.blueprints.api import BlueprintInstanceSerializer
return BlueprintInstanceSerializer
def __str__(self) -> str:
return f"Blueprint Instance {self.name}"
class Meta:
verbose_name = _("Blueprint Instance")
verbose_name_plural = _("Blueprint Instances")
unique_together = (
(
"name",
"path",
),
)

View File

@ -1,12 +0,0 @@
"""blueprint Settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"blueprints_v1_discover": {
"task": "authentik.blueprints.v1.tasks.blueprints_discover",
"schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"),
"options": {"queue": "authentik_scheduled"},
},
}

View File

@ -1,48 +0,0 @@
"""Blueprint helpers"""
from functools import wraps
from pathlib import Path
from typing import Callable
from django.apps import apps
from authentik.blueprints.apps import ManagedAppConfig
from authentik.blueprints.models import BlueprintInstance
from authentik.lib.config import CONFIG
def apply_blueprint(*files: str):
"""Apply blueprint before test"""
from authentik.blueprints.v1.importer import Importer
def wrapper_outer(func: Callable):
"""Apply blueprint before test"""
@wraps(func)
def wrapper(*args, **kwargs):
for file in files:
content = BlueprintInstance(path=file).retrieve()
Importer(content).apply()
return func(*args, **kwargs)
return wrapper
return wrapper_outer
def reconcile_app(app_name: str):
"""Re-reconcile AppConfig methods"""
def wrapper_outer(func: Callable):
"""Re-reconcile AppConfig methods"""
@wraps(func)
def wrapper(*args, **kwargs):
config = apps.get_app_config(app_name)
if isinstance(config, ManagedAppConfig):
config.reconcile()
return func(*args, **kwargs)
return wrapper
return wrapper_outer

View File

@ -1,34 +0,0 @@
"""authentik managed models tests"""
from typing import Callable, Type
from django.apps import apps
from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.lib.models import SerializerModel
class TestModels(TestCase):
"""Test Models"""
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
"""Test serializer"""
def tester(self: TestModels):
if test_model._meta.abstract:
return
model_class = test_model()
self.assertTrue(isinstance(model_class, SerializerModel))
self.assertIsNotNone(model_class.serializer)
return tester
for app in apps.get_app_configs():
if not app.label.startswith("authentik"):
continue
for model in app.get_models():
if not is_model_allowed(model):
continue
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))

View File

@ -1,97 +0,0 @@
"""Test blueprints OCI"""
from django.test import TransactionTestCase
from requests_mock import Mocker
from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed
class TestBlueprintOCI(TransactionTestCase):
"""Test Blueprints OCI Tasks"""
def test_successful(self):
"""Successful retrieval"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={
"layers": [
{
"mediaType": OCI_MEDIA_TYPE,
"digest": "foo",
}
]
},
)
mocker.get("https://ghcr.io/v2/goauthentik/blueprints/test/blobs/foo", text="foo")
self.assertEqual(
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci(),
"foo",
)
def test_manifests_error(self):
"""Test manifests request erroring"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest", status_code=401
)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()
def test_manifests_error_response(self):
"""Test manifests request erroring"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={"errors": ["foo"]},
)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()
def test_no_matching_blob(self):
"""Successful retrieval"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={
"layers": [
{
"mediaType": OCI_MEDIA_TYPE + "foo",
"digest": "foo",
}
]
},
)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()
def test_blob_error(self):
"""Successful retrieval"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={
"layers": [
{
"mediaType": OCI_MEDIA_TYPE,
"digest": "foo",
}
]
},
)
mocker.get("https://ghcr.io/v2/goauthentik/blueprints/test/blobs/foo", status_code=401)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()

View File

@ -1,38 +0,0 @@
"""test packaged blueprints"""
from pathlib import Path
from typing import Callable
from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.tests import apply_blueprint
from authentik.blueprints.v1.importer import Importer
from authentik.tenants.models import Tenant
class TestPackaged(TransactionTestCase):
"""Empty class, test methods are added dynamically"""
@apply_blueprint("default/90-default-tenant.yaml")
def test_decorator_static(self):
"""Test @apply_blueprint decorator"""
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
def blueprint_tester(file_name: Path) -> Callable:
"""This is used instead of subTest for better visibility"""
def tester(self: TestPackaged):
base = Path("blueprints/")
rel_path = Path(file_name).relative_to(base)
importer = Importer(BlueprintInstance(path=str(rel_path)).retrieve())
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
return tester
for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
if "local" in str(blueprint_file):
continue
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))

View File

@ -1,45 +0,0 @@
"""Test blueprints v1 api"""
from json import loads
from tempfile import NamedTemporaryFile, mkdtemp
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import dump
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.config import CONFIG
TMP = mkdtemp("authentik-blueprints")
class TestBlueprintsV1API(APITestCase):
"""Test Blueprints API"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.client.force_login(self.user)
@CONFIG.patch("blueprints_dir", TMP)
def test_api_available(self):
"""Test valid file"""
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
}
)
)
file.flush()
res = self.client.get(reverse("authentik_api:blueprintinstance-available"))
self.assertEqual(res.status_code, 200)
response = loads(res.content.decode())
self.assertEqual(len(response), 1)
self.assertEqual(
response[0]["hash"],
(
"e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1bd1f9b352"
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
),
)

View File

@ -1,148 +0,0 @@
"""Test blueprints v1 tasks"""
from hashlib import sha512
from tempfile import NamedTemporaryFile, mkdtemp
from django.test import TransactionTestCase
from yaml import dump
from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_discover, blueprints_find
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
TMP = mkdtemp("authentik-blueprints")
class TestBlueprintsV1Tasks(TransactionTestCase):
"""Test Blueprints v1 Tasks"""
@CONFIG.patch("blueprints_dir", TMP)
def test_invalid_file_syntax(self):
"""Test syntactically invalid file"""
with NamedTemporaryFile(suffix=".yaml", dir=TMP) as file:
file.write(b"{")
file.flush()
blueprints = blueprints_find()
self.assertEqual(blueprints, [])
@CONFIG.patch("blueprints_dir", TMP)
def test_invalid_file_version(self):
"""Test invalid file"""
with NamedTemporaryFile(suffix=".yaml", dir=TMP) as file:
file.write(b"version: 2")
file.flush()
blueprints = blueprints_find()
self.assertEqual(blueprints, [])
@CONFIG.patch("blueprints_dir", TMP)
def test_valid(self):
"""Test valid file"""
blueprint_id = generate_id()
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
"metadata": {
"name": blueprint_id,
},
}
)
)
file.seek(0)
file_hash = sha512(file.read().encode()).hexdigest()
file.flush()
blueprints_discover() # pylint: disable=no-value-for-parameter
instance = BlueprintInstance.objects.filter(name=blueprint_id).first()
self.assertEqual(instance.last_applied_hash, file_hash)
self.assertEqual(
instance.metadata,
{
"name": blueprint_id,
"labels": {},
},
)
@CONFIG.patch("blueprints_dir", TMP)
def test_valid_updated(self):
"""Test valid file"""
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
}
)
)
file.flush()
blueprints_discover() # pylint: disable=no-value-for-parameter
self.assertEqual(
BlueprintInstance.objects.first().last_applied_hash,
(
"e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1b"
"d1f9b3526871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
),
)
self.assertEqual(BlueprintInstance.objects.first().metadata, {})
file.write(
dump(
{
"version": 1,
"entries": [],
"metadata": {
"name": "foo",
},
}
)
)
file.flush()
blueprints_discover() # pylint: disable=no-value-for-parameter
self.assertEqual(
BlueprintInstance.objects.first().last_applied_hash,
(
"fc62fea96067da8592bdf90927246d0ca150b045447df93b0652a0e20a8bc327"
"681510b5db37ea98759c61f9a98dd2381f46a3b5a2da69dfb45158897f14e824"
),
)
self.assertEqual(
BlueprintInstance.objects.first().metadata,
{
"name": "foo",
"labels": {},
},
)
@CONFIG.patch("blueprints_dir", TMP)
def test_valid_disabled(self):
"""Test valid file"""
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
}
)
)
file.flush()
instance: BlueprintInstance = BlueprintInstance.objects.create(
name=generate_id(),
path=file.name,
enabled=False,
status=BlueprintInstanceStatus.UNKNOWN,
)
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)
apply_blueprint(instance.pk) # pylint: disable=no-value-for-parameter
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)

View File

@ -1,261 +0,0 @@
"""transfer common classes"""
from collections import OrderedDict
from dataclasses import asdict, dataclass, field, is_dataclass
from enum import Enum
from typing import Any, Optional
from uuid import UUID
from django.apps import apps
from django.db.models import Model, Q
from rest_framework.fields import Field
from rest_framework.serializers import Serializer
from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.models import PolicyBindingModel
def get_attrs(obj: SerializerModel) -> dict[str, Any]:
"""Get object's attributes via their serializer, and convert it to a normal dict"""
serializer: Serializer = obj.serializer(obj)
data = dict(serializer.data)
for field_name, _field in serializer.fields.items():
_field: Field
if field_name not in data:
continue
if _field.read_only:
data.pop(field_name, None)
if _field.get_initial() == data.get(field_name, None):
data.pop(field_name, None)
if field_name.endswith("_set"):
data.pop(field_name, None)
return data
@dataclass
class BlueprintEntryState:
"""State of a single instance"""
instance: Optional[Model] = None
@dataclass
class BlueprintEntry:
"""Single entry of a blueprint"""
model: str
identifiers: dict[str, Any] = field(default_factory=dict)
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
# pylint: disable=invalid-name
id: Optional[str] = None
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
@staticmethod
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
"""Convert a SerializerModel instance to a blueprint Entry"""
identifiers = {
"pk": model.pk,
}
all_attrs = get_attrs(model)
for extra_identifier_name in extra_identifier_names:
identifiers[extra_identifier_name] = all_attrs.pop(extra_identifier_name)
return BlueprintEntry(
identifiers=identifiers,
model=f"{model._meta.app_label}.{model._meta.model_name}",
attrs=all_attrs,
)
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
"""Check if we have any special tags that need handling"""
if isinstance(value, YAMLTag):
return value.resolve(self, blueprint)
if isinstance(value, dict):
for key, inner_value in value.items():
value[key] = self.tag_resolver(inner_value, blueprint)
if isinstance(value, list):
for idx, inner_value in enumerate(value):
value[idx] = self.tag_resolver(inner_value, blueprint)
return value
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.attrs, blueprint)
def get_identifiers(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.identifiers, blueprint)
@dataclass
class BlueprintMetadata:
"""Optional blueprint metadata"""
name: str
labels: dict[str, str] = field(default_factory=dict)
@dataclass
class Blueprint:
"""Dataclass used for a full export"""
version: int = field(default=1)
entries: list[BlueprintEntry] = field(default_factory=list)
context: dict = field(default_factory=dict)
metadata: Optional[BlueprintMetadata] = field(default=None)
class YAMLTag:
"""Base class for all YAML Tags"""
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
"""Implement yaml tag logic"""
raise NotImplementedError
class KeyOf(YAMLTag):
"""Reference another object by their ID"""
id_from: str
# pylint: disable=unused-argument
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.id_from = node.value
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
for _entry in blueprint.entries:
if _entry.id == self.id_from and _entry._state.instance:
# Special handling for PolicyBindingModels, as they'll have a different PK
# which is used when creating policy bindings
if (
isinstance(_entry._state.instance, PolicyBindingModel)
and entry.model.lower() == "authentik_policies.policybinding"
):
return _entry._state.instance.pbm_uuid
return _entry._state.instance.pk
raise ValueError(
f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance"
)
class Context(YAMLTag):
"""Lookup key from instance context"""
key: str
default: Optional[Any]
# pylint: disable=unused-argument
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
self.key = node.value
if isinstance(node, SequenceNode):
self.key = node.value[0].value
self.default = node.value[1].value
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
value = self.default
if self.key in blueprint.context:
value = blueprint.context[self.key]
return value
class Format(YAMLTag):
"""Format a string"""
format_string: str
args: list[Any]
# pylint: disable=unused-argument
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.format_string = node.value[0].value
self.args = []
for raw_node in node.value[1:]:
self.args.append(raw_node.value)
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
try:
return self.format_string % tuple(self.args)
except TypeError as exc:
raise EntryInvalidError(exc)
class Find(YAMLTag):
"""Find any object"""
model_name: str
conditions: list[list]
model_class: type[Model]
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.model_name = node.value[0].value
self.model_class = apps.get_model(*self.model_name.split("."))
self.conditions = []
for raw_node in node.value[1:]:
values = []
for node_values in raw_node.value:
values.append(loader.construct_object(node_values))
self.conditions.append(values)
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
query = Q()
for cond in self.conditions:
query &= Q(**{cond[0]: cond[1]})
instance = self.model_class.objects.filter(query).first()
if instance:
return instance.pk
return None
class BlueprintDumper(SafeDumper):
"""Dump dataclasses to yaml"""
default_flow_style = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_representer(UUID, lambda self, data: self.represent_str(str(data)))
self.add_representer(OrderedDict, lambda self, data: self.represent_dict(dict(data)))
self.add_representer(Enum, lambda self, data: self.represent_str(data.value))
def represent(self, data) -> None:
if is_dataclass(data):
def factory(items):
final_dict = dict(items)
final_dict.pop("_state", None)
return final_dict
data = asdict(data, dict_factory=factory)
return super().represent(data)
class BlueprintLoader(SafeLoader):
"""Loader for blueprints with custom tag support"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_constructor("!KeyOf", KeyOf)
self.add_constructor("!Find", Find)
self.add_constructor("!Context", Context)
self.add_constructor("!Format", Format)
class EntryInvalidError(SentryIgnoredException):
"""Error raised when an entry is invalid"""
serializer_errors: Optional[dict]
def __init__(self, *args: object, serializer_errors: Optional[dict] = None) -> None:
super().__init__(*args)
self.serializer_errors = serializer_errors

View File

@ -1,149 +0,0 @@
"""Blueprint exporter"""
from typing import Iterable
from uuid import UUID
from django.apps import apps
from django.contrib.auth import get_user_model
from django.db.models import Model, Q, QuerySet
from django.utils.timezone import now
from django.utils.translation import gettext as _
from guardian.shortcuts import get_anonymous_user
from yaml import dump
from authentik.blueprints.v1.common import (
Blueprint,
BlueprintDumper,
BlueprintEntry,
BlueprintMetadata,
)
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_GENERATED
from authentik.events.models import Event
from authentik.flows.models import Flow, FlowStageBinding, Stage
from authentik.policies.models import Policy, PolicyBinding
from authentik.stages.prompt.models import PromptStage
class Exporter:
"""Export flow with attached stages into yaml"""
excluded_models: list[type[Model]] = []
def __init__(self):
self.excluded_models = [
Event,
]
def get_entries(self) -> Iterable[BlueprintEntry]:
"""Get blueprint entries"""
for model in apps.get_models():
if not is_model_allowed(model):
continue
if model in self.excluded_models:
continue
for obj in self.get_model_instances(model):
yield BlueprintEntry.from_model(obj)
def get_model_instances(self, model: type[Model]) -> QuerySet:
"""Return a queryset for `model`. Can be used to filter some
objects on some models"""
if model == get_user_model():
return model.objects.exclude(pk=get_anonymous_user().pk)
return model.objects.all()
def _pre_export(self, blueprint: Blueprint):
"""Hook to run anything pre-export"""
def export(self) -> Blueprint:
"""Create a list of all objects and create a blueprint"""
blueprint = Blueprint()
self._pre_export(blueprint)
blueprint.metadata = BlueprintMetadata(
name=_("authentik Export - %(date)s" % {"date": str(now())}),
labels={
LABEL_AUTHENTIK_GENERATED: "true",
},
)
blueprint.entries = list(self.get_entries())
return blueprint
def export_to_string(self) -> str:
"""Call export and convert it to yaml"""
blueprint = self.export()
return dump(blueprint, Dumper=BlueprintDumper)
class FlowExporter(Exporter):
"""Exporter customised to only return objects related to `flow`"""
flow: Flow
with_policies: bool
with_stage_prompts: bool
pbm_uuids: list[UUID]
def __init__(self, flow: Flow):
super().__init__()
self.flow = flow
self.with_policies = True
self.with_stage_prompts = True
def _pre_export(self, blueprint: Blueprint):
if not self.with_policies:
return
self.pbm_uuids = [self.flow.pbm_uuid]
self.pbm_uuids += FlowStageBinding.objects.filter(target=self.flow).values_list(
"pbm_uuid", flat=True
)
def walk_stages(self) -> Iterable[BlueprintEntry]:
"""Convert all stages attached to self.flow into BlueprintEntry objects"""
stages = Stage.objects.filter(flow=self.flow).select_related().select_subclasses()
for stage in stages:
if isinstance(stage, PromptStage):
pass
yield BlueprintEntry.from_model(stage, "name")
def walk_stage_bindings(self) -> Iterable[BlueprintEntry]:
"""Convert all bindings attached to self.flow into BlueprintEntry objects"""
bindings = FlowStageBinding.objects.filter(target=self.flow).select_related()
for binding in bindings:
yield BlueprintEntry.from_model(binding, "target", "stage", "order")
def walk_policies(self) -> Iterable[BlueprintEntry]:
"""Walk over all policies. This is done at the beginning of the export for stages that have
a direct foreign key to a policy."""
# Special case for PromptStage as that has a direct M2M to policy, we have to ensure
# all policies referenced in there we also include here
prompt_stages = PromptStage.objects.filter(flow=self.flow).values_list("pk", flat=True)
query = Q(bindings__in=self.pbm_uuids) | Q(promptstage__in=prompt_stages)
policies = Policy.objects.filter(query).select_related()
for policy in policies:
yield BlueprintEntry.from_model(policy)
def walk_policy_bindings(self) -> Iterable[BlueprintEntry]:
"""Walk over all policybindings relative to us. This is run at the end of the export, as
we are sure all objects exist now."""
bindings = PolicyBinding.objects.filter(target__in=self.pbm_uuids).select_related()
for binding in bindings:
yield BlueprintEntry.from_model(binding, "policy", "target", "order")
def walk_stage_prompts(self) -> Iterable[BlueprintEntry]:
"""Walk over all prompts associated with any PromptStages"""
prompt_stages = PromptStage.objects.filter(flow=self.flow)
for stage in prompt_stages:
for prompt in stage.fields.all():
yield BlueprintEntry.from_model(prompt)
def get_entries(self) -> Iterable[BlueprintEntry]:
entries = []
entries.append(BlueprintEntry.from_model(self.flow, "slug"))
if self.with_stage_prompts:
entries.extend(self.walk_stage_prompts())
if self.with_policies:
entries.extend(self.walk_policies())
entries.extend(self.walk_stages())
entries.extend(self.walk_stage_bindings())
if self.with_policies:
entries.extend(self.walk_policy_bindings())
return entries

View File

@ -1,5 +0,0 @@
"""Blueprint labels"""
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
LABEL_AUTHENTIK_INSTANTIATE = "blueprints.goauthentik.io/instantiate"
LABEL_AUTHENTIK_GENERATED = "blueprints.goauthentik.io/generated"

View File

@ -1,60 +0,0 @@
"""Apply Blueprint meta model"""
from typing import TYPE_CHECKING
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, JSONField
from structlog.stdlib import get_logger
from authentik.blueprints.v1.meta.registry import BaseMetaModel, MetaResult, registry
from authentik.core.api.utils import PassiveSerializer, is_dict
if TYPE_CHECKING:
from authentik.blueprints.models import BlueprintInstance
LOGGER = get_logger()
class ApplyBlueprintMetaSerializer(PassiveSerializer):
"""Serializer for meta apply blueprint model"""
identifiers = JSONField(validators=[is_dict])
required = BooleanField(default=True)
# We cannot override `instance` as that will confuse rest_framework
# and make it attempt to update the instance
blueprint_instance: "BlueprintInstance"
def validate(self, attrs):
from authentik.blueprints.models import BlueprintInstance
identifiers = attrs["identifiers"]
required = attrs["required"]
instance = BlueprintInstance.objects.filter(**identifiers).first()
if not instance and required:
raise ValidationError("Required blueprint does not exist")
self.blueprint_instance = instance
return super().validate(attrs)
def create(self, validated_data: dict) -> MetaResult:
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)
# pylint: disable=no-value-for-parameter
apply_blueprint(str(self.blueprint_instance.pk))
return MetaResult()
@registry.register("metaapplyblueprint")
class MetaApplyBlueprint(BaseMetaModel):
"""Meta model to apply another blueprint"""
@staticmethod
def serializer() -> ApplyBlueprintMetaSerializer:
return ApplyBlueprintMetaSerializer
class Meta:
abstract = True

View File

@ -1,61 +0,0 @@
"""Base models"""
from django.apps import apps
from django.db.models import Model
from rest_framework.serializers import Serializer
class BaseMetaModel(Model):
"""Base models"""
@staticmethod
def serializer() -> Serializer:
"""Serializer similar to SerializerModel, but as a static method since
this is an abstract model"""
raise NotImplementedError
class Meta:
abstract = True
class MetaResult:
"""Result returned by Meta Models' serializers. Empty class but we can't return none as
the framework doesn't allow that"""
class MetaModelRegistry:
"""Registry for pseudo meta models"""
models: dict[str, BaseMetaModel]
virtual_prefix: str
def __init__(self, prefix: str) -> None:
self.models = {}
self.virtual_prefix = prefix
def register(self, model_id: str):
"""Register model class under `model_id`"""
def inner_wrapper(cls):
self.models[model_id] = cls
return cls
return inner_wrapper
def get_models(self):
"""Wrapper for django's `get_models` to list all models"""
models = apps.get_models()
for _, value in self.models.items():
models.append(value)
return models
def get_model(self, app_label: str, model_id: str) -> type[Model]:
"""Get model checks if any virtual models are registered, and falls back
to actual django models"""
if app_label.lower() == self.virtual_prefix:
if model_id.lower() in self.models:
return self.models[model_id]
return apps.get_model(app_label, model_id)
registry = MetaModelRegistry("authentik_blueprints")

View File

@ -1,181 +0,0 @@
"""v1 blueprints tasks"""
from dataclasses import asdict, dataclass, field
from hashlib import sha512
from pathlib import Path
from typing import Optional
from dacite.core import from_dict
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 structlog.stdlib import get_logger
from yaml import load
from yaml.error import YAMLError
from authentik.blueprints.models import (
BlueprintInstance,
BlueprintInstanceStatus,
BlueprintRetrievalFailed,
)
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, EntryInvalidError
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.events.utils import sanitize_dict
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@dataclass
class BlueprintFile:
"""Basic info about a blueprint file"""
path: str
version: int
hash: str
last_m: int
meta: Optional[BlueprintMetadata] = field(default=None)
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
def blueprints_find_dict():
"""Find blueprints as `blueprints_find` does, but return a safe dict"""
blueprints = []
for blueprint in blueprints_find():
blueprints.append(sanitize_dict(asdict(blueprint)))
return blueprints
def blueprints_find():
"""Find blueprints and return valid ones"""
blueprints = []
root = Path(CONFIG.y("blueprints_dir"))
for file in root.glob("**/*.yaml"):
path = Path(file)
LOGGER.debug("found blueprint", path=str(path))
with open(path, "r", encoding="utf-8") as blueprint_file:
try:
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
except YAMLError as exc:
raw_blueprint = None
LOGGER.warning("failed to parse blueprint", exc=exc, path=str(path))
if not raw_blueprint:
continue
metadata = raw_blueprint.get("metadata", None)
version = raw_blueprint.get("version", 1)
if version != 1:
LOGGER.warning("invalid blueprint version", version=version, path=str(path))
continue
file_hash = sha512(path.read_bytes()).hexdigest()
blueprint = BlueprintFile(
str(path.relative_to(root)), version, file_hash, int(path.stat().st_mtime)
)
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
blueprints.append(blueprint)
LOGGER.info(
"parsed & loaded blueprint",
hash=file_hash,
path=str(path),
)
return blueprints
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True
)
@prefill_task
def blueprints_discover(self: MonitoredTask):
"""Find blueprints and check if they need to be created in the database"""
count = 0
for blueprint in blueprints_find():
check_blueprint_v1_file(blueprint)
count += 1
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL,
messages=[_("Successfully imported %(count)d files." % {"count": count})],
)
)
def check_blueprint_v1_file(blueprint: BlueprintFile):
"""Check if blueprint should be imported"""
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=blueprint.path).first()
if (
blueprint.meta
and blueprint.meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false"
):
return
if not instance:
instance = BlueprintInstance(
name=blueprint.meta.name if blueprint.meta else str(blueprint.path),
path=blueprint.path,
context={},
status=BlueprintInstanceStatus.UNKNOWN,
enabled=True,
managed_models=[],
metadata={},
)
instance.save()
if instance.last_applied_hash != blueprint.hash:
apply_blueprint.delay(str(instance.pk))
@CELERY_APP.task(
bind=True,
base=MonitoredTask,
)
def apply_blueprint(self: MonitoredTask, instance_pk: str):
"""Apply single blueprint"""
self.save_on_success = False
instance: Optional[BlueprintInstance] = None
try:
instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
self.set_uid(slugify(instance.name))
if not instance or not instance.enabled:
return
blueprint_content = instance.retrieve()
file_hash = sha512(blueprint_content.encode()).hexdigest()
importer = Importer(blueprint_content, instance.context)
if importer.blueprint.metadata:
instance.metadata = asdict(importer.blueprint.metadata)
valid, logs = importer.validate()
if not valid:
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs]))
return
applied = importer.apply()
if not applied:
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply"))
return
instance.status = BlueprintInstanceStatus.SUCCESSFUL
instance.last_applied_hash = file_hash
instance.last_applied = now()
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
except (
DatabaseError,
ProgrammingError,
InternalError,
IOError,
BlueprintRetrievalFailed,
EntryInvalidError,
) as exc:
if instance:
instance.status = BlueprintInstanceStatus.ERROR
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
finally:
if instance:
instance.save()

View File

@ -50,9 +50,7 @@ class ApplicationSerializer(ModelSerializer):
def get_launch_url(self, app: Application) -> Optional[str]:
"""Allow formatting of launch URL"""
user = None
if "request" in self.context:
user = self.context["request"].user
user = self.context["request"].user
return app.get_launch_url(user)
class Meta:
@ -100,6 +98,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"group",
]
lookup_field = "slug"
filterset_fields = ["name", "slug"]
ordering = ["name"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:

View File

@ -1,10 +1,9 @@
"""Authenticator Devices API Views"""
from django_otp import device_classes, devices_for_user
from django_otp import devices_for_user
from django_otp.models import Device
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.fields import BooleanField, CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from drf_spectacular.utils import extend_schema
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
@ -18,7 +17,6 @@ class DeviceSerializer(MetaNameSerializer):
pk = IntegerField()
name = CharField()
type = SerializerMethodField()
confirmed = BooleanField()
def get_type(self, instance: Device) -> str:
"""Get type of device"""
@ -36,33 +34,3 @@ class DeviceViewSet(ViewSet):
"""Get all devices for current user"""
devices = devices_for_user(request.user)
return Response(DeviceSerializer(devices, many=True).data)
class AdminDeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
serializer_class = DeviceSerializer
permission_classes = [IsAdminUser]
def get_devices(self, **kwargs):
"""Get all devices in all child classes"""
for model in device_classes():
device_set = model.objects.filter(**kwargs)
yield from device_set
@extend_schema(
parameters=[
OpenApiParameter(
name="user",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
)
],
responses={200: DeviceSerializer(many=True)},
)
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
kwargs = {}
if "user" in request.query_params:
kwargs = {"user": request.query_params["user"]}
return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data)

View File

@ -62,11 +62,6 @@ class GroupSerializer(ModelSerializer):
"attributes",
"users_obj",
]
extra_kwargs = {
"users": {
"default": list,
}
}
class GroupFilter(FilterSet):

View File

@ -14,12 +14,12 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import GenericViewSet
from authentik.api.decorators import permission_required
from authentik.blueprints.api import ManagedSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer
from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.expression import PropertyMappingEvaluator
from authentik.core.models import PropertyMapping
from authentik.lib.utils.reflection import all_subclasses
from authentik.managed.api import ManagedSerializer
from authentik.policies.api.exec import PolicyTestSerializer
@ -41,9 +41,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
def validate_expression(self, expression: str) -> str:
"""Test Syntax"""
evaluator = PropertyMappingEvaluator(
self.instance,
)
evaluator = PropertyMappingEvaluator()
evaluator.validate(expression)
return expression

View File

@ -15,13 +15,13 @@ from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.api.decorators import permission_required
from authentik.blueprints.api import ManagedSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
from authentik.managed.api import ManagedSerializer
class TokenSerializer(ManagedSerializer, ModelSerializer):

View File

@ -1,37 +1,22 @@
"""authentik core app config"""
from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikCoreConfig(ManagedAppConfig):
class AuthentikCoreConfig(AppConfig):
"""authentik core app config"""
name = "authentik.core"
label = "authentik_core"
verbose_name = "authentik Core"
mountpoint = ""
default = True
def reconcile_load_core_signals(self):
"""Load core signals"""
self.import_module("authentik.core.signals")
def reconcile_debug_worker_hook(self):
"""Dispatch startup tasks inline when debugging"""
def ready(self):
import_module("authentik.core.signals")
import_module("authentik.core.managed")
if settings.DEBUG:
from authentik.root.celery import worker_ready_hook
worker_ready_hook()
def reconcile_source_inbuilt(self):
"""Reconcile inbuilt source"""
from authentik.core.models import Source
Source.objects.update_or_create(
defaults={
"name": "authentik Built-in",
"slug": "authentik-built-in",
},
managed="goauthentik.io/sources/inbuilt",
)

View File

@ -2,33 +2,28 @@
from traceback import format_tb
from typing import Optional
from django.db.models import Model
from django.http import HttpRequest
from guardian.utils import get_anonymous_user
from authentik.core.models import User
from authentik.core.models import PropertyMapping, User
from authentik.events.models import Event, EventAction
from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.policies.types import PolicyRequest
class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evaluator that adds some different context variables."""
"""Custom Evalautor that adds some different context variables."""
def __init__(
def set_context(
self,
model: Model,
user: Optional[User] = None,
request: Optional[HttpRequest] = None,
user: Optional[User],
request: Optional[HttpRequest],
mapping: PropertyMapping,
**kwargs,
):
if hasattr(model, "name"):
_filename = model.name
else:
_filename = str(model)
super().__init__(filename=_filename)
"""Update context with context from PropertyMapping's evaluate"""
req = PolicyRequest(user=get_anonymous_user())
req.obj = model
req.obj = mapping
if user:
req.user = user
self._context["user"] = user

17
authentik/core/managed.py Normal file
View File

@ -0,0 +1,17 @@
"""Core managed objects"""
from authentik.core.models import Source
from authentik.managed.manager import EnsureExists, ObjectManager
class CoreManager(ObjectManager):
"""Core managed objects"""
def reconcile(self):
return [
EnsureExists(
Source,
"goauthentik.io/sources/inbuilt",
name="authentik Built-in",
slug="authentik-built-in",
),
]

View File

@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand
from authentik.root.celery import _get_startup_tasks
class Command(BaseCommand):
class Command(BaseCommand): # pragma: no cover
"""Run bootstrap tasks to ensure certain objects are created"""
def handle(self, **options):

View File

@ -5,7 +5,7 @@ from django.core.management.base import BaseCommand, no_translations
from guardian.management import create_anonymous_user
class Command(BaseCommand):
class Command(BaseCommand): # pragma: no cover
"""Repair missing permissions"""
@no_translations

View File

@ -7,9 +7,9 @@ from django.core.management.base import BaseCommand
from django.db.models import Model
from django.db.models.signals import post_save, pre_delete
from authentik import get_full_version
from authentik import __version__
from authentik.core.models import User
from authentik.events.middleware import should_log_model
from authentik.events.middleware import IGNORED_MODELS
from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
@ -18,11 +18,11 @@ BANNER_TEXT = """### authentik shell ({authentik})
node=platform.node(),
python=platform.python_version(),
arch=platform.machine(),
authentik=get_full_version(),
authentik=__version__,
)
class Command(BaseCommand):
class Command(BaseCommand): # pragma: no cover
"""Start the Django shell with all authentik models already imported"""
django_models = {}
@ -40,6 +40,9 @@ class Command(BaseCommand):
# Gather Django models and constants from each app
for app in apps.get_app_configs():
if not app.name.startswith("authentik"):
continue
# Load models from each app
for model in app.get_models():
namespace[model.__name__] = model
@ -50,7 +53,7 @@ class Command(BaseCommand):
# pylint: disable=unused-argument
def post_save_handler(sender, instance: Model, created: bool, **_):
"""Signal handler for all object's post_save"""
if not should_log_model(instance):
if isinstance(instance, IGNORED_MODELS):
return
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
@ -66,7 +69,7 @@ class Command(BaseCommand):
# pylint: disable=unused-argument
def pre_delete_handler(sender, instance: Model, **_):
"""Signal handler for all object's pre_delete"""
if not should_log_model(instance): # pragma: no cover
if isinstance(instance, IGNORED_MODELS): # pragma: no cover
return
Event.new(EventAction.MODEL_DELETED, model=model_to_dict(instance)).set_user(

View File

@ -1,22 +1,19 @@
"""authentik admin Middleware to impersonate users"""
from contextvars import ContextVar
from typing import Callable, Optional
from logging import Logger
from threading import local
from typing import Callable
from uuid import uuid4
from django.http import HttpRequest, HttpResponse
from sentry_sdk.api import set_tag
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
SESSION_KEY_IMPERSONATE_USER = "authentik/impersonate/user"
SESSION_KEY_IMPERSONATE_ORIGINAL_USER = "authentik/impersonate/original_user"
LOCAL = local()
RESPONSE_HEADER_ID = "X-authentik-id"
KEY_AUTH_VIA = "auth_via"
KEY_USER = "user"
CTX_REQUEST_ID = ContextVar[Optional[str]](STRUCTLOG_KEY_PREFIX + "request_id", default=None)
CTX_HOST = ContextVar[Optional[str]](STRUCTLOG_KEY_PREFIX + "host", default=None)
CTX_AUTH_VIA = ContextVar[Optional[str]](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None)
class ImpersonateMiddleware:
"""Middleware to impersonate users"""
@ -50,20 +47,26 @@ class RequestIDMiddleware:
if not hasattr(request, "request_id"):
request_id = uuid4().hex
setattr(request, "request_id", request_id)
CTX_REQUEST_ID.set(request_id)
CTX_HOST.set(request.get_host())
LOCAL.authentik = {
"request_id": request_id,
"host": request.get_host(),
}
set_tag("authentik.request_id", request_id)
if hasattr(request, "user") and getattr(request.user, "is_authenticated", False):
CTX_AUTH_VIA.set("session")
else:
CTX_AUTH_VIA.set("unauthenticated")
response = self.get_response(request)
response[RESPONSE_HEADER_ID] = request.request_id
setattr(response, "ak_context", {})
response.ak_context["request_id"] = CTX_REQUEST_ID.get()
response.ak_context["host"] = CTX_HOST.get()
response.ak_context[KEY_AUTH_VIA] = CTX_AUTH_VIA.get()
response.ak_context[KEY_USER] = request.user.username
response.ak_context.update(LOCAL.authentik)
response.ak_context.setdefault(KEY_USER, request.user.username)
for key in list(LOCAL.authentik.keys()):
del LOCAL.authentik[key]
return response
# pylint: disable=unused-argument
def structlog_add_request_id(logger: Logger, method_name: str, event_dict: dict):
"""If threadlocal has authentik defined, add request_id to log"""
if hasattr(LOCAL, "authentik"):
event_dict.update(LOCAL.authentik)
if hasattr(LOCAL, "authentik_task"):
event_dict.update(LOCAL.authentik_task)
return event_dict

View File

@ -1,26 +0,0 @@
# Generated by Django 4.0.6 on 2022-08-05 22:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0021_source_user_path_user_path"),
]
operations = [
migrations.AlterField(
model_name="group",
name="parent",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="authentik_core.group",
),
),
]

View File

@ -23,14 +23,14 @@ from model_utils.managers import InheritanceManager
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.blueprints.models import ManagedModel
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.lib.config import CONFIG, get_path_from_dict
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
from authentik.lib.utils.http import get_client_ip
from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel
LOGGER = get_logger()
@ -68,7 +68,7 @@ def default_token_key():
return generate_id(int(CONFIG.y("default_token_length")))
class Group(SerializerModel):
class Group(models.Model):
"""Custom Group model which supports a basic hierarchy"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -82,18 +82,11 @@ class Group(SerializerModel):
"Group",
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name="children",
)
attributes = models.JSONField(default=dict, blank=True)
@property
def serializer(self) -> Serializer:
from authentik.core.api.groups import GroupSerializer
return GroupSerializer
@property
def num_pk(self) -> int:
"""Get a numerical, int32 ID for the group"""
@ -146,7 +139,7 @@ class UserManager(DjangoUserManager):
return self._create_user(username, email, password, **extra_fields)
class User(SerializerModel, GuardianUserMixin, AbstractUser):
class User(GuardianUserMixin, AbstractUser):
"""Custom User model to allow easier adding of user-based settings"""
uuid = models.UUIDField(default=uuid4, editable=False)
@ -177,12 +170,6 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
always_merger.merge(final_attributes, self.attributes)
return final_attributes
@property
def serializer(self) -> Serializer:
from authentik.core.api.users import UserSerializer
return UserSerializer
@cached_property
def is_superuser(self) -> bool:
"""Get supseruser status based on membership in a group with superuser status"""
@ -226,8 +213,6 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
mode: str = CONFIG.y("avatars", "none")
if mode == "none":
return DEFAULT_AVATAR
if mode.startswith("attributes."):
return get_path_from_dict(self.attributes, mode[11:], default=DEFAULT_AVATAR)
# gravatar uses md5 for their URLs, so md5 can't be avoided
mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest() # nosec
if mode == "gravatar":
@ -289,7 +274,7 @@ class Provider(SerializerModel):
return self.name
class Application(SerializerModel, PolicyBindingModel):
class Application(PolicyBindingModel):
"""Every Application which uses authentik for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to
add custom fields and other properties"""
@ -320,12 +305,6 @@ class Application(SerializerModel, PolicyBindingModel):
meta_description = models.TextField(default="", blank=True)
meta_publisher = models.TextField(default="", blank=True)
@property
def serializer(self) -> Serializer:
from authentik.core.api.applications import ApplicationSerializer
return ApplicationSerializer
@property
def get_meta_icon(self) -> Optional[str]:
"""Get the URL to the App Icon image. If the name is /static or starts with http
@ -473,7 +452,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
return self.name
class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
class UserSourceConnection(CreatedUpdatedModel):
"""Connection between User and Source."""
user = models.ForeignKey(User, on_delete=models.CASCADE)
@ -481,11 +460,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
objects = InheritanceManager()
@property
def serializer(self) -> type[Serializer]:
"""Get serializer for this model"""
raise NotImplementedError
class Meta:
unique_together = (("user", "source"),)
@ -540,7 +514,7 @@ class TokenIntents(models.TextChoices):
INTENT_APP_PASSWORD = "app_password" # nosec
class Token(SerializerModel, ManagedModel, ExpiringModel):
class Token(ManagedModel, ExpiringModel):
"""Token used to authenticate the User for API Access or confirm another Stage like Email."""
token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -552,12 +526,6 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+")
description = models.TextField(default="", blank=True)
@property
def serializer(self) -> type[Serializer]:
from authentik.core.api.tokens import TokenSerializer
return TokenSerializer
def expire_action(self, *args, **kwargs):
"""Handler which is called when this object is expired."""
from authentik.events.models import Event, EventAction
@ -617,9 +585,10 @@ class PropertyMapping(SerializerModel, ManagedModel):
def evaluate(self, user: Optional[User], request: Optional[HttpRequest], **kwargs) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context."""
from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.expression import PropertyMappingEvaluator
evaluator = PropertyMappingEvaluator(self, user, request, **kwargs)
evaluator = PropertyMappingEvaluator()
evaluator.set_context(user, request, self, **kwargs)
try:
return evaluator.evaluate(self.expression)
except Exception as exc:

View File

@ -0,0 +1,31 @@
{% extends 'base/skeleton.html' %}
{% load i18n %}
{% block head %}
{{ block.super }}
<style>
.pf-c-empty-state {
height: 100vh;
}
</style>
{% endblock %}
{% block body %}
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<i class="fas fa-exclamation-circle pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">
{% trans title %}
</h1>
<div class="pf-c-empty-state__body">
{% if message %}
<h3>{% trans message %}</h3>
{% endif %}
</div>
<a href="/" class="pf-c-button pf-m-primary pf-m-block">{% trans 'Go to home' %}</a>
</div>
</div>
</section>
{% endblock %}

View File

@ -4,17 +4,9 @@
{% load i18n %}
{% block head %}
<script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script>
<script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
<script>
window.authentik = {};
window.authentik.locale = "{{ tenant.default_locale }}";
window.authentik.config = JSON.parse('{{ config_json|escapejs }}');
window.authentik.tenant = JSON.parse('{{ tenant_json|escapejs }}');
</script>
{% endblock %}
{% block body %}

View File

@ -1,21 +0,0 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block title %}
{% trans 'End session' %} - {{ tenant.branding_title }}
{% endblock %}
{% block card_title %}
{% trans title %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
<p>{% trans message %}</p>
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">
{% trans 'Go home' %}
</a>
</form>
{% endblock %}

View File

@ -6,16 +6,13 @@
{% block head_before %}
{{ block.super }}
<link rel="prefetch" href="{{ flow.background_url }}" />
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% if flow.compatibility_mode and not inspector %}
<script>ShadyDOM = { force: !navigator.webdriver };</script>
{% endif %}
<script>
window.authentik = {};
window.authentik.locale = "{{ tenant.default_locale }}";
window.authentik.config = JSON.parse('{{ config_json|escapejs }}');
window.authentik.tenant = JSON.parse('{{ tenant_json|escapejs }}');
window.authentik = {
"locale": "{{ tenant.default_locale }}",
};
window.authentik.flow = {
"layout": "{{ flow.layout }}",
};
@ -23,7 +20,7 @@ window.authentik.flow = {
{% endblock %}
{% block head %}
<script src="{% static 'dist/flow/FlowInterface.js' %}?version={{ version }}" type="module"></script>
<script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script>
<style>
:root {
--ak-flow-background: url("{{ flow.background_url }}");

View File

@ -4,17 +4,9 @@
{% load i18n %}
{% block head %}
<script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script>
<script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script>
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
<script>
window.authentik = {};
window.authentik.locale = "{{ tenant.default_locale }}";
window.authentik.config = JSON.parse('{{ config_json|escapejs }}');
window.authentik.tenant = JSON.parse('{{ tenant_json|escapejs }}');
</script>
{% endblock %}
{% block body %}

View File

@ -5,7 +5,8 @@ from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.models import Flow
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import OAuth2Provider
@ -19,7 +20,10 @@ class TestApplicationsAPI(APITestCase):
self.provider = OAuth2Provider.objects.create(
name="test",
redirect_uris="http://some-other-domain",
authorization_flow=create_test_flow(),
authorization_flow=Flow.objects.create(
name="test",
slug="test",
),
)
self.allowed = Application.objects.create(
name="allowed",

View File

@ -4,7 +4,8 @@ from unittest.mock import MagicMock, patch
from django.urls import reverse
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.core.tests.utils import create_test_admin_user, create_test_tenant
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.tests import FlowTestCase
from authentik.tenants.models import Tenant
@ -20,7 +21,11 @@ class TestApplicationsViews(FlowTestCase):
def test_check_redirect(self):
"""Test redirect"""
empty_flow = create_test_flow()
empty_flow = Flow.objects.create(
name="foo",
slug="foo",
designation=FlowDesignation.AUTHENTICATION,
)
tenant: Tenant = create_test_tenant()
tenant.flow_authentication = empty_flow
tenant.save()
@ -44,7 +49,11 @@ class TestApplicationsViews(FlowTestCase):
def test_check_redirect_auth(self):
"""Test redirect"""
self.client.force_login(self.user)
empty_flow = create_test_flow()
empty_flow = Flow.objects.create(
name="foo",
slug="foo",
designation=FlowDesignation.AUTHENTICATION,
)
tenant: Tenant = create_test_tenant()
tenant.flow_authentication = empty_flow
tenant.save()

View File

@ -6,7 +6,7 @@ from guardian.utils import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action
from authentik.core.tests.utils import create_test_flow
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request
from authentik.policies.denied import AccessDeniedResponse
@ -152,7 +152,9 @@ class TestSourceFlowManager(TestCase):
"""Test error handling when a source selected flow is non-applicable due to a policy"""
self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_LINK
flow = create_test_flow()
flow = Flow.objects.create(
name="test", slug="test", title="test", designation=FlowDesignation.ENROLLMENT
)
policy = ExpressionPolicy.objects.create(
name="false", expression="""ak_message("foo");return False"""
)

View File

@ -1,13 +1,10 @@
"""Test Users API"""
from json import loads
from django.urls.base import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.models import FlowDesignation
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant
@ -159,6 +156,7 @@ class TestUsersAPI(APITestCase):
response = self.client.get(
reverse("authentik_api:user-paths"),
)
print(response.content)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(response.content.decode(), {"paths": ["users"]})
@ -213,47 +211,3 @@ class TestUsersAPI(APITestCase):
self.assertJSONEqual(
response.content.decode(), {"path": ["No empty segments in user path allowed."]}
)
def test_me(self):
"""Test user's me endpoint"""
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
@CONFIG.patch("avatars", "none")
def test_avatars_none(self):
"""Test avatars none"""
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
@CONFIG.patch("avatars", "gravatar")
def test_avatars_gravatar(self):
"""Test avatars gravatar"""
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertIn("gravatar", body["user"]["avatar"])
@CONFIG.patch("avatars", "foo-%(username)s")
def test_avatars_custom(self):
"""Test avatars custom"""
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["user"]["avatar"], f"foo-{self.admin.username}")
@CONFIG.patch("avatars", "attributes.foo.avatar")
def test_avatars_attributes(self):
"""Test avatars attributes"""
self.admin.attributes = {"foo": {"avatar": "bar"}}
self.admin.save()
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["user"]["avatar"], "bar")

View File

@ -52,5 +52,5 @@ def create_test_cert() -> CertificateKeyPair:
subject_alt_names=["goauthentik.io"],
validity_days=360,
)
builder.common_name = generate_id()
builder.name = generate_id()
return builder.save()

View File

@ -4,10 +4,11 @@ from django.contrib.auth.decorators import login_required
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import RedirectView
from django.views.generic.base import TemplateView
from authentik.core.views import apps, impersonate
from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
from authentik.core.views.interface import FlowInterfaceView
from authentik.core.views.session import EndSessionView
urlpatterns = [
@ -38,12 +39,12 @@ urlpatterns = [
# Interfaces
path(
"if/admin/",
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/admin.html")),
ensure_csrf_cookie(TemplateView.as_view(template_name="if/admin.html")),
name="if-admin",
),
path(
"if/user/",
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/user.html")),
ensure_csrf_cookie(TemplateView.as_view(template_name="if/user.html")),
name="if-user",
),
path(
@ -57,10 +58,10 @@ urlpatterns = [
name="if-session-end",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
path("ws/outpost/<uuid:pk>/", TemplateView.as_view(template_name="if/admin.html")),
path(
"ws/client/",
InterfaceView.as_view(template_name="if/admin.html"),
TemplateView.as_view(template_name="if/admin.html"),
),
]

View File

@ -32,7 +32,7 @@ class BadRequestView(TemplateView):
extra_context = {"title": "Bad Request"}
response_class = BadRequestTemplateResponse
template_name = "if/error.html"
template_name = "error/generic.html"
class ForbiddenView(TemplateView):
@ -41,7 +41,7 @@ class ForbiddenView(TemplateView):
extra_context = {"title": "Forbidden"}
response_class = ForbiddenTemplateResponse
template_name = "if/error.html"
template_name = "error/generic.html"
class NotFoundView(TemplateView):
@ -50,7 +50,7 @@ class NotFoundView(TemplateView):
extra_context = {"title": "Not Found"}
response_class = NotFoundTemplateResponse
template_name = "if/error.html"
template_name = "error/generic.html"
class ServerErrorView(TemplateView):
@ -59,7 +59,7 @@ class ServerErrorView(TemplateView):
extra_context = {"title": "Server Error"}
response_class = ServerErrorTemplateResponse
template_name = "if/error.html"
template_name = "error/generic.html"
# pylint: disable=useless-super-delegation
def dispatch(self, *args, **kwargs): # pragma: no cover

View File

@ -1,26 +1,13 @@
"""Interface views"""
from json import dumps
from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from rest_framework.request import Request
from authentik.api.v3.config import ConfigView
from authentik.flows.models import Flow
from authentik.tenants.api import CurrentTenantSerializer
class InterfaceView(TemplateView):
"""Base interface view"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
kwargs["tenant_json"] = dumps(CurrentTenantSerializer(self.request.tenant).data)
return super().get_context_data(**kwargs)
class FlowInterfaceView(InterfaceView):
class FlowInterfaceView(TemplateView):
"""Flow interface"""
template_name = "if/flow.html"

View File

@ -12,19 +12,18 @@ from django_filters.filters import BooleanFilter
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField, IntegerField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.serializers import ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.decorators import permission_required
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.managed import MANAGED_KEY
from authentik.crypto.models import CertificateKeyPair
from authentik.events.models import Event, EventAction

View File

@ -1,72 +1,16 @@
"""authentik crypto app config"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from importlib import import_module
from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.generators import generate_id
if TYPE_CHECKING:
from authentik.crypto.models import CertificateKeyPair
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
from django.apps import AppConfig
class AuthentikCryptoConfig(ManagedAppConfig):
class AuthentikCryptoConfig(AppConfig):
"""authentik crypto app config"""
name = "authentik.crypto"
label = "authentik_crypto"
verbose_name = "authentik Crypto"
default = True
def reconcile_load_crypto_tasks(self):
"""Load crypto tasks"""
self.import_module("authentik.crypto.tasks")
def _create_update_cert(self, cert: Optional["CertificateKeyPair"] = None):
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
builder = CertificateBuilder()
builder.common_name = "goauthentik.io"
builder.build(
subject_alt_names=["goauthentik.io"],
validity_days=360,
)
if not cert:
cert = CertificateKeyPair()
cert.certificate_data = builder.certificate
cert.key_data = builder.private_key
cert.name = "authentik Internal JWT Certificate"
cert.managed = MANAGED_KEY
cert.save()
def reconcile_managed_jwt_cert(self):
"""Ensure managed JWT certificate"""
from authentik.crypto.models import CertificateKeyPair
certs = CertificateKeyPair.objects.filter(managed=MANAGED_KEY)
if not certs.exists():
self._create_update_cert()
return
cert: CertificateKeyPair = certs.first()
now = datetime.now()
if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after:
self._create_update_cert(cert)
def reconcile_self_signed(self):
"""Create self-signed keypair"""
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
name = "authentik Self-signed Certificate"
if CertificateKeyPair.objects.filter(name=name).exists():
return
builder = CertificateBuilder()
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
CertificateKeyPair.objects.create(
name="authentik Self-signed Certificate",
certificate_data=builder.certificate,
key_data=builder.private_key,
)
def ready(self):
import_module("authentik.crypto.managed")
import_module("authentik.crypto.tasks")

View File

@ -26,7 +26,7 @@ class CertificateBuilder:
self.common_name = "authentik Self-signed Certificate"
self.cert = CertificateKeyPair()
def save(self) -> CertificateKeyPair:
def save(self) -> Optional[CertificateKeyPair]:
"""Save generated certificate as model"""
if not self.__certificate:
raise ValueError("Certificated hasn't been built yet")

View File

@ -0,0 +1,40 @@
"""Crypto managed objects"""
from datetime import datetime
from typing import Optional
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
from authentik.managed.manager import ObjectManager
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
class CryptoManager(ObjectManager):
"""Crypto managed objects"""
def _create(self, cert: Optional[CertificateKeyPair] = None):
builder = CertificateBuilder()
builder.common_name = "goauthentik.io"
builder.build(
subject_alt_names=["goauthentik.io"],
validity_days=360,
)
if not cert:
cert = CertificateKeyPair()
cert.certificate_data = builder.certificate
cert.key_data = builder.private_key
cert.name = "authentik Internal JWT Certificate"
cert.managed = MANAGED_KEY
cert.save()
def reconcile(self):
certs = CertificateKeyPair.objects.filter(managed=MANAGED_KEY)
if not certs.exists():
self._create()
return []
cert: CertificateKeyPair = certs.first()
now = datetime.now()
if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after:
self._create(cert)
return []
return []

View File

@ -1,51 +0,0 @@
"""Import certificate"""
from sys import exit as sys_exit
from django.core.management.base import BaseCommand, no_translations
from rest_framework.exceptions import ValidationError
from structlog.stdlib import get_logger
from authentik.crypto.api import CertificateKeyPairSerializer
from authentik.crypto.models import CertificateKeyPair
LOGGER = get_logger()
class Command(BaseCommand):
"""Import certificate"""
@no_translations
def handle(self, *args, **options):
"""Import certificate"""
keypair = CertificateKeyPair.objects.filter(name=options["name"]).first()
dirty = False
if not keypair:
keypair = CertificateKeyPair(name=options["name"])
dirty = True
with open(options["certificate"], mode="r", encoding="utf-8") as _cert:
cert_data = _cert.read()
if keypair.certificate_data != cert_data:
dirty = True
keypair.certificate_data = cert_data
if options["private_key"]:
with open(options["private_key"], mode="r", encoding="utf-8") as _key:
key_data = _key.read()
if keypair.key_data != key_data:
dirty = True
keypair.key_data = key_data
# Validate that cert and key are actually PEM and valid
serializer = CertificateKeyPairSerializer(instance=keypair)
try:
serializer.validate_certificate_data(keypair.certificate_data)
if keypair.key_data != "":
serializer.validate_certificate_data(keypair.key_data)
except ValidationError as exc:
self.stderr.write(exc)
sys_exit(1)
if dirty:
keypair.save()
def add_arguments(self, parser):
parser.add_argument("--certificate", type=str, required=True)
parser.add_argument("--private-key", type=str, required=False)
parser.add_argument("--name", type=str, required=True)

View File

@ -5,10 +5,24 @@ from django.db import migrations
from authentik.lib.generators import generate_id
def create_self_signed(apps, schema_editor):
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
db_alias = schema_editor.connection.alias
from authentik.crypto.builder import CertificateBuilder
builder = CertificateBuilder()
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
CertificateKeyPair.objects.using(db_alias).create(
name="authentik Self-signed Certificate",
certificate_data=builder.certificate,
key_data=builder.private_key,
)
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0001_initial"),
]
operations = []
operations = [migrations.RunPython(create_self_signed)]

View File

@ -6,21 +6,25 @@ from uuid import uuid4
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES, PUBLIC_KEY_TYPES
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey,
EllipticCurvePublicKey,
)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import Certificate, load_pem_x509_certificate
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.blueprints.models import ManagedModel
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.models import CreatedUpdatedModel
from authentik.managed.models import ManagedModel
LOGGER = get_logger()
class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
"""CertificateKeyPair that can be used for signing or encrypting if `key_data`
is set, otherwise it can be used to verify remote data."""
@ -37,14 +41,8 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
)
_cert: Optional[Certificate] = None
_private_key: Optional[PRIVATE_KEY_TYPES] = None
_public_key: Optional[PUBLIC_KEY_TYPES] = None
@property
def serializer(self) -> Serializer:
from authentik.crypto.api import CertificateKeyPairSerializer
return CertificateKeyPairSerializer
_private_key: Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey] = None
_public_key: Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey] = None
@property
def certificate(self) -> Certificate:
@ -56,7 +54,7 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
return self._cert
@property
def public_key(self) -> Optional[PUBLIC_KEY_TYPES]:
def public_key(self) -> Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey]:
"""Get public key of the private key"""
if not self._public_key:
self._public_key = self.private_key.public_key()
@ -65,7 +63,7 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
@property
def private_key(
self,
) -> Optional[PRIVATE_KEY_TYPES]:
) -> Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey]:
"""Get python cryptography PrivateKey instance"""
if not self._private_key and self.key_data != "":
try:

View File

@ -85,18 +85,16 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
"""Send example notification using selected transport. Requires
Modify permissions."""
transport: NotificationTransport = self.get_object()
event = Event.new(
action="notification_test",
user=get_user(request.user),
app=self.__class__.__module__,
context={"foo": "bar"},
)
event.save()
notification = Notification(
severity=NotificationSeverity.NOTICE,
body=f"Test Notification from transport {transport.name}",
user=request.user,
event=event,
event=Event(
action="Test",
user=get_user(request.user),
app=self.__class__.__module__,
context={"foo": "bar"},
),
)
try:
response = NotificationTransportTestSerializer(

View File

@ -1,7 +1,8 @@
"""authentik events app"""
from prometheus_client import Gauge
from importlib import import_module
from authentik.blueprints.apps import ManagedAppConfig
from django.apps import AppConfig
from prometheus_client import Gauge
GAUGE_TASKS = Gauge(
"authentik_system_tasks",
@ -10,14 +11,12 @@ GAUGE_TASKS = Gauge(
)
class AuthentikEventsConfig(ManagedAppConfig):
class AuthentikEventsConfig(AppConfig):
"""authentik events app"""
name = "authentik.events"
label = "authentik_events"
verbose_name = "authentik Events"
default = True
def reconcile_load_events_signals(self):
"""Load events signals"""
self.import_module("authentik.events.signals")
def ready(self):
import_module("authentik.events.signals")

View File

@ -11,6 +11,7 @@ from django.http import HttpRequest, HttpResponse
from django_otp.plugins.otp_static.models import StaticToken
from guardian.models import UserObjectPermission
from authentik.core.middleware import LOCAL
from authentik.core.models import AuthenticatedSession, User
from authentik.events.models import Event, EventAction, Notification
from authentik.events.signals import EventNewThread
@ -19,7 +20,7 @@ from authentik.flows.models import FlowToken
from authentik.lib.sentry import before_send
from authentik.lib.utils.errors import exception_to_string
IGNORED_MODELS = (
IGNORED_MODELS = [
Event,
Notification,
UserObjectPermission,
@ -27,14 +28,12 @@ IGNORED_MODELS = (
StaticToken,
Session,
FlowToken,
)
]
if settings.DEBUG:
from silk.models import Request, Response, SQLQuery
def should_log_model(model: Model) -> bool:
"""Return true if operation on `model` should be logged"""
if model.__module__.startswith("silk"):
return False
return not isinstance(model, IGNORED_MODELS)
IGNORED_MODELS += [Request, Response, SQLQuery]
IGNORED_MODELS = tuple(IGNORED_MODELS)
class AuditMiddleware:
@ -46,46 +45,36 @@ class AuditMiddleware:
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def connect(self, request: HttpRequest):
"""Connect signal for automatic logging"""
if not hasattr(request, "user"):
return
if not getattr(request.user, "is_authenticated", False):
return
if not hasattr(request, "request_id"):
return
post_save_handler = partial(self.post_save_handler, user=request.user, request=request)
pre_delete_handler = partial(self.pre_delete_handler, user=request.user, request=request)
post_save.connect(
post_save_handler,
dispatch_uid=request.request_id,
weak=False,
)
pre_delete.connect(
pre_delete_handler,
dispatch_uid=request.request_id,
weak=False,
)
def disconnect(self, request: HttpRequest):
"""Disconnect signals"""
if not hasattr(request, "request_id"):
return
post_save.disconnect(dispatch_uid=request.request_id)
pre_delete.disconnect(dispatch_uid=request.request_id)
def __call__(self, request: HttpRequest) -> HttpResponse:
self.connect(request)
# Connect signal for automatic logging
if hasattr(request, "user") and getattr(request.user, "is_authenticated", False):
post_save_handler = partial(self.post_save_handler, user=request.user, request=request)
pre_delete_handler = partial(
self.pre_delete_handler, user=request.user, request=request
)
post_save.connect(
post_save_handler,
dispatch_uid=LOCAL.authentik["request_id"],
weak=False,
)
pre_delete.connect(
pre_delete_handler,
dispatch_uid=LOCAL.authentik["request_id"],
weak=False,
)
response = self.get_response(request)
self.disconnect(request)
post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
return response
# pylint: disable=unused-argument
def process_exception(self, request: HttpRequest, exception: Exception):
"""Disconnect handlers in case of exception"""
self.disconnect(request)
post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
if settings.DEBUG:
return
@ -111,7 +100,7 @@ class AuditMiddleware:
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
):
"""Signal handler for all object's post_save"""
if not should_log_model(instance):
if isinstance(instance, IGNORED_MODELS):
return
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
@ -121,7 +110,7 @@ class AuditMiddleware:
# pylint: disable=unused-argument
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
"""Signal handler for all object's pre_delete"""
if not should_log_model(instance): # pragma: no cover
if isinstance(instance, IGNORED_MODELS): # pragma: no cover
return
EventNewThread(

View File

@ -28,6 +28,126 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
event.save()
def notify_configuration_error(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-configuration-error",
defaults={"action": EventAction.CONFIGURATION_ERROR},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-configuration-error",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy,
defaults={
"order": 0,
},
)
def notify_update(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-update",
defaults={"action": EventAction.UPDATE_AVAILABLE},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-update",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy,
defaults={
"order": 0,
},
)
def notify_exception(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy_policy_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-policy-exception",
defaults={"action": EventAction.POLICY_EXCEPTION},
)
policy_pm_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-property-mapping-exception",
defaults={"action": EventAction.PROPERTY_MAPPING_EXCEPTION},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-exception",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy_policy_exc,
defaults={
"order": 0,
},
)
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy_pm_exc,
defaults={
"order": 1,
},
)
def transport_email_global(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
NotificationTransport.objects.using(db_alias).update_or_create(
name="default-email-transport",
defaults={"mode": TransportMode.EMAIL},
)
def token_view_to_secret_view(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.events.models import EventAction
@ -312,6 +432,18 @@ class Migration(migrations.Migration):
"verbose_name_plural": "Notifications",
},
),
migrations.RunPython(
code=transport_email_global,
),
migrations.RunPython(
code=notify_configuration_error,
),
migrations.RunPython(
code=notify_update,
),
migrations.RunPython(
code=notify_exception,
),
migrations.AddField(
model_name="notificationtransport",
name="send_once",

View File

@ -1,5 +1,29 @@
# Generated by Django 4.0.4 on 2022-05-30 18:08
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from authentik.events.models import TransportMode
def notify_local_transport(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
local_transport, _ = NotificationTransport.objects.using(db_alias).update_or_create(
name="default-local-transport",
defaults={"mode": TransportMode.LOCAL},
)
for trigger in NotificationRule.objects.using(db_alias).filter(
name__in=[
"default-notify-configuration-error",
"default-notify-exception",
"default-notify-update",
]
):
trigger.transports.add(local_transport)
class Migration(migrations.Migration):
@ -22,4 +46,5 @@ class Migration(migrations.Migration):
default="local",
),
),
migrations.RunPython(notify_local_transport),
]

View File

@ -1,6 +1,130 @@
# Generated by Django 3.1.4 on 2021-01-10 18:57
from django.apps.registry import Apps
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from authentik.events.models import EventAction, NotificationSeverity, TransportMode
def notify_configuration_error(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-configuration-error",
defaults={"action": EventAction.CONFIGURATION_ERROR},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-configuration-error",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy,
defaults={
"order": 0,
},
)
def notify_update(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-update",
defaults={"action": EventAction.UPDATE_AVAILABLE},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-update",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy,
defaults={
"order": 0,
},
)
def notify_exception(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy_policy_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-policy-exception",
defaults={"action": EventAction.POLICY_EXCEPTION},
)
policy_pm_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-property-mapping-exception",
defaults={"action": EventAction.PROPERTY_MAPPING_EXCEPTION},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-exception",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy_policy_exc,
defaults={
"order": 0,
},
)
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy_pm_exc,
defaults={
"order": 1,
},
)
def transport_email_global(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
NotificationTransport.objects.using(db_alias).update_or_create(
name="default-email-transport",
defaults={"mode": TransportMode.EMAIL},
)
class Migration(migrations.Migration):
@ -10,6 +134,14 @@ class Migration(migrations.Migration):
"authentik_events",
"0010_notification_notificationtransport_notificationrule",
),
("authentik_core", "0016_auto_20201202_2234"),
("authentik_policies_event_matcher", "0003_auto_20210110_1907"),
("authentik_policies", "0004_policy_execution_logging"),
]
operations = []
operations = [
migrations.RunPython(transport_email_global),
migrations.RunPython(notify_configuration_error),
migrations.RunPython(notify_update),
migrations.RunPython(notify_exception),
]

View File

@ -22,21 +22,15 @@ from django.utils.translation import gettext as _
from requests import RequestException
from structlog.stdlib import get_logger
from authentik import get_full_version
from authentik import __version__
from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER,
)
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
from authentik.events.geo import GEOIP_READER
from authentik.events.utils import (
cleanse_dict,
get_user,
model_to_dict,
sanitize_dict,
sanitize_item,
)
from authentik.lib.models import DomainlessURLValidator, SerializerModel
from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.http import get_client_ip, get_http_session
from authentik.lib.utils.time import timedelta_from_string
@ -174,7 +168,7 @@ class EventManager(Manager):
return self.get_queryset().get_events_per_day()
class Event(SerializerModel, ExpiringModel):
class Event(ExpiringModel):
"""An individual Audit/Metrics/Notification/Error Event"""
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -279,12 +273,6 @@ class Event(SerializerModel, ExpiringModel):
)
super().save(*args, **kwargs)
@property
def serializer(self) -> "Serializer":
from authentik.events.api.events import EventSerializer
return EventSerializer
@property
def summary(self) -> str:
"""Return a summary of this event."""
@ -310,7 +298,7 @@ class TransportMode(models.TextChoices):
EMAIL = "email", _("Email")
class NotificationTransport(SerializerModel):
class NotificationTransport(models.Model):
"""Action which is executed when a Rule matches"""
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -361,12 +349,10 @@ class NotificationTransport(SerializerModel):
"user_username": notification.user.username,
}
if self.webhook_mapping:
default_body = sanitize_item(
self.webhook_mapping.evaluate(
user=notification.user,
request=None,
notification=notification,
)
default_body = self.webhook_mapping.evaluate(
user=notification.user,
request=None,
notification=notification,
)
try:
response = get_http_session().post(
@ -414,7 +400,7 @@ class NotificationTransport(SerializerModel):
"title": notification.body,
"color": "#fd4b2d",
"fields": fields,
"footer": f"authentik {get_full_version()}",
"footer": f"authentik v{__version__}",
}
],
}
@ -462,12 +448,6 @@ class NotificationTransport(SerializerModel):
except (SMTPException, ConnectionError, OSError) as exc:
raise NotificationTransportError from exc
@property
def serializer(self) -> "Serializer":
from authentik.events.api.notification_transports import NotificationTransportSerializer
return NotificationTransportSerializer
def __str__(self) -> str:
return f"Notification Transport {self.name}"
@ -485,7 +465,7 @@ class NotificationSeverity(models.TextChoices):
ALERT = "alert", _("Alert")
class Notification(SerializerModel):
class Notification(models.Model):
"""Event Notification"""
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -496,12 +476,6 @@ class Notification(SerializerModel):
seen = models.BooleanField(default=False)
user = models.ForeignKey(User, on_delete=models.CASCADE)
@property
def serializer(self) -> "Serializer":
from authentik.events.api.notifications import NotificationSerializer
return NotificationSerializer
def __str__(self) -> str:
body_trunc = (self.body[:75] + "..") if len(self.body) > 75 else self.body
return f"Notification for user {self.user}: {body_trunc}"
@ -512,7 +486,7 @@ class Notification(SerializerModel):
verbose_name_plural = _("Notifications")
class NotificationRule(SerializerModel, PolicyBindingModel):
class NotificationRule(PolicyBindingModel):
"""Decide when to create a Notification based on policies attached to this object."""
name = models.TextField(unique=True)
@ -544,12 +518,6 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
on_delete=models.SET_NULL,
)
@property
def serializer(self) -> "Serializer":
from authentik.events.api.notification_rules import NotificationRuleSerializer
return NotificationRuleSerializer
def __str__(self) -> str:
return f"Notification Rule {self.name}"

View File

@ -134,31 +134,26 @@ class MonitoredTask(Task):
# pylint: disable=too-many-arguments
def after_return(self, status, retval, task_id, args: list[Any], kwargs: dict[str, Any], einfo):
super().after_return(status, retval, task_id, args, kwargs, einfo=einfo)
if not self._result:
return
if not self._result.uid:
self._result.uid = self._uid
info = TaskInfo(
task_name=self.__name__,
task_description=self.__doc__,
start_timestamp=self.start,
finish_timestamp=default_timer(),
finish_time=datetime.now(),
result=self._result,
task_call_module=self.__module__,
task_call_func=self.__name__,
task_call_args=args,
task_call_kwargs=kwargs,
)
if self._result.status == TaskResultStatus.SUCCESSFUL and not self.save_on_success:
info.delete()
return
info.save(self.result_timeout_hours)
if self._result:
if not self._result.uid:
self._result.uid = self._uid
if self.save_on_success:
TaskInfo(
task_name=self.__name__,
task_description=self.__doc__,
start_timestamp=self.start,
finish_timestamp=default_timer(),
finish_time=datetime.now(),
result=self._result,
task_call_module=self.__module__,
task_call_func=self.__name__,
task_call_args=args,
task_call_kwargs=kwargs,
).save(self.result_timeout_hours)
return super().after_return(status, retval, task_id, args, kwargs, einfo=einfo)
# pylint: disable=too-many-arguments
def on_failure(self, exc, task_id, args, kwargs, einfo):
super().on_failure(exc, task_id, args, kwargs, einfo=einfo)
if not self._result:
self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[str(exc)])
if not self._result.uid:
@ -179,6 +174,7 @@ class MonitoredTask(Task):
EventAction.SYSTEM_TASK_EXCEPTION,
message=(f"Task {self.__name__} encountered an error: {exception_to_string(exc)}"),
).save()
return super().on_failure(exc, task_id, args, kwargs, einfo=einfo)
def run(self, *args, **kwargs):
raise NotImplementedError

View File

@ -31,8 +31,8 @@ class TestEventsNotifications(TestCase):
def test_trigger_empty(self):
"""Test trigger without any policies attached"""
transport = NotificationTransport.objects.create(name=generate_id())
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
transport = NotificationTransport.objects.create(name="transport")
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
trigger.transports.add(transport)
trigger.save()
@ -43,8 +43,8 @@ class TestEventsNotifications(TestCase):
def test_trigger_single(self):
"""Test simple transport triggering"""
transport = NotificationTransport.objects.create(name=generate_id())
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
transport = NotificationTransport.objects.create(name="transport")
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
trigger.transports.add(transport)
trigger.save()
matcher = EventMatcherPolicy.objects.create(
@ -59,7 +59,7 @@ class TestEventsNotifications(TestCase):
def test_trigger_no_group(self):
"""Test trigger without group"""
trigger = NotificationRule.objects.create(name=generate_id())
trigger = NotificationRule.objects.create(name="trigger")
matcher = EventMatcherPolicy.objects.create(
name="matcher", action=EventAction.CUSTOM_PREFIX
)
@ -72,9 +72,9 @@ class TestEventsNotifications(TestCase):
def test_policy_error_recursive(self):
"""Test Policy error which would cause recursion"""
transport = NotificationTransport.objects.create(name=generate_id())
transport = NotificationTransport.objects.create(name="transport")
NotificationRule.objects.filter(name__startswith="default").delete()
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
trigger.transports.add(transport)
trigger.save()
matcher = EventMatcherPolicy.objects.create(
@ -95,9 +95,9 @@ class TestEventsNotifications(TestCase):
self.group.users.add(user2)
self.group.save()
transport = NotificationTransport.objects.create(name=generate_id(), send_once=True)
transport = NotificationTransport.objects.create(name="transport", send_once=True)
NotificationRule.objects.filter(name__startswith="default").delete()
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
trigger.transports.add(transport)
trigger.save()
matcher = EventMatcherPolicy.objects.create(
@ -118,10 +118,10 @@ class TestEventsNotifications(TestCase):
)
transport = NotificationTransport.objects.create(
name=generate_id(), webhook_mapping=mapping, mode=TransportMode.LOCAL
name="transport", webhook_mapping=mapping, mode=TransportMode.LOCAL
)
NotificationRule.objects.filter(name__startswith="default").delete()
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
trigger.transports.add(transport)
matcher = EventMatcherPolicy.objects.create(
name="matcher", action=EventAction.CUSTOM_PREFIX

View File

@ -1,131 +0,0 @@
"""transport tests"""
from unittest.mock import PropertyMock, patch
from django.core import mail
from django.core.mail.backends.locmem import EmailBackend
from django.test import TestCase
from requests_mock import Mocker
from authentik import get_full_version
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import (
Event,
Notification,
NotificationSeverity,
NotificationTransport,
NotificationWebhookMapping,
TransportMode,
)
from authentik.lib.generators import generate_id
class TestEventTransports(TestCase):
"""Test Event Transports"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.event = Event.new("foo", "testing", foo="bar,").set_user(self.user)
self.event.save()
self.notification = Notification.objects.create(
severity=NotificationSeverity.ALERT,
body="foo",
event=self.event,
user=self.user,
)
def test_transport_webhook(self):
"""Test webhook transport"""
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.WEBHOOK,
webhook_url="http://localhost:1234/test",
)
with Mocker() as mocker:
mocker.post("http://localhost:1234/test")
transport.send(self.notification)
self.assertEqual(mocker.call_count, 1)
self.assertEqual(mocker.request_history[0].method, "POST")
self.assertJSONEqual(
mocker.request_history[0].body.decode(),
{
"body": "foo",
"severity": "alert",
"user_email": self.user.email,
"user_username": self.user.username,
},
)
def test_transport_webhook_mapping(self):
"""Test webhook transport with custom mapping"""
mapping = NotificationWebhookMapping.objects.create(
name=generate_id(), expression="return request.user"
)
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.WEBHOOK,
webhook_url="http://localhost:1234/test",
webhook_mapping=mapping,
)
with Mocker() as mocker:
mocker.post("http://localhost:1234/test")
transport.send(self.notification)
self.assertEqual(mocker.call_count, 1)
self.assertEqual(mocker.request_history[0].method, "POST")
self.assertJSONEqual(
mocker.request_history[0].body.decode(),
{"email": self.user.email, "pk": self.user.pk, "username": self.user.username},
)
def test_transport_webhook_slack(self):
"""Test webhook transport (slack)"""
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.WEBHOOK_SLACK,
webhook_url="http://localhost:1234/test",
)
with Mocker() as mocker:
mocker.post("http://localhost:1234/test")
transport.send(self.notification)
self.assertEqual(mocker.call_count, 1)
self.assertEqual(mocker.request_history[0].method, "POST")
self.assertJSONEqual(
mocker.request_history[0].body.decode(),
{
"username": "authentik",
"icon_url": "https://goauthentik.io/img/icon.png",
"attachments": [
{
"author_name": "authentik",
"author_link": "https://goauthentik.io",
"author_icon": "https://goauthentik.io/img/icon.png",
"title": "custom_foo",
"color": "#fd4b2d",
"fields": [
{"title": "Severity", "value": "alert", "short": True},
{
"title": "Dispatched for user",
"value": self.user.username,
"short": True,
},
{"title": "foo", "value": "bar,"},
],
"footer": f"authentik {get_full_version()}",
}
],
},
)
def test_transport_email(self):
"""Test email transport"""
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.EMAIL,
)
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
transport.send(self.notification)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik Notification: custom_foo")
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])

View File

@ -1,7 +1,6 @@
"""event utilities"""
import re
from dataclasses import asdict, is_dataclass
from pathlib import Path
from typing import Any, Optional
from uuid import UUID
@ -23,31 +22,21 @@ from authentik.policies.types import PolicyRequest
ALLOWED_SPECIAL_KEYS = re.compile("passing", flags=re.I)
def cleanse_item(key: str, value: Any) -> Any:
"""Cleanse a single item"""
if isinstance(value, dict):
return cleanse_dict(value)
if isinstance(value, list):
for idx, item in enumerate(value):
value[idx] = cleanse_item(key, item)
return value
try:
if SafeExceptionReporterFilter.hidden_settings.search(
key
) and not ALLOWED_SPECIAL_KEYS.search(key):
return SafeExceptionReporterFilter.cleansed_substitute
except TypeError: # pragma: no cover
return value
return value
def cleanse_dict(source: dict[Any, Any]) -> dict[Any, Any]:
"""Cleanse a dictionary, recursively"""
final_dict = {}
for key, value in source.items():
new_value = cleanse_item(key, value)
if new_value is not ...:
final_dict[key] = new_value
try:
if SafeExceptionReporterFilter.hidden_settings.search(
key
) and not ALLOWED_SPECIAL_KEYS.search(key):
final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute
else:
final_dict[key] = value
except TypeError: # pragma: no cover
final_dict[key] = value
if isinstance(value, dict):
final_dict[key] = cleanse_dict(value)
return final_dict
@ -80,45 +69,6 @@ def get_user(user: User, original_user: Optional[User] = None) -> dict[str, Any]
return user_data
# pylint: disable=too-many-return-statements
def sanitize_item(value: Any) -> Any:
"""Sanitize a single item, ensure it is JSON parsable"""
if is_dataclass(value):
# Because asdict calls `copy.deepcopy(obj)` on everything that's not tuple/dict,
# and deepcopy doesn't work with HttpRequests (neither django nor rest_framework).
# Currently, the only dataclass that actually holds an http request is a PolicyRequest
if isinstance(value, PolicyRequest):
value.http_request = None
value = asdict(value)
if isinstance(value, dict):
return sanitize_dict(value)
if isinstance(value, list):
new_values = []
for item in value:
new_value = sanitize_item(item)
if new_value:
new_values.append(new_value)
return new_values
if isinstance(value, (User, AnonymousUser)):
return sanitize_dict(get_user(value))
if isinstance(value, models.Model):
return sanitize_dict(model_to_dict(value))
if isinstance(value, UUID):
return value.hex
if isinstance(value, (HttpRequest, WSGIRequest)):
return ...
if isinstance(value, City):
return GEOIP_READER.city_to_dict(value)
if isinstance(value, Path):
return str(value)
if isinstance(value, type):
return {
"type": value.__name__,
"module": value.__module__,
}
return value
def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
"""clean source of all Models that would interfere with the JSONField.
Models are replaced with a dictionary of {
@ -128,7 +78,30 @@ def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
}"""
final_dict = {}
for key, value in source.items():
new_value = sanitize_item(value)
if new_value is not ...:
final_dict[key] = new_value
if is_dataclass(value):
# Because asdict calls `copy.deepcopy(obj)` on everything that's not tuple/dict,
# and deepcopy doesn't work with HttpRequests (neither django nor rest_framework).
# Currently, the only dataclass that actually holds an http request is a PolicyRequest
if isinstance(value, PolicyRequest):
value.http_request = None
value = asdict(value)
if isinstance(value, dict):
final_dict[key] = sanitize_dict(value)
elif isinstance(value, (User, AnonymousUser)):
final_dict[key] = sanitize_dict(get_user(value))
elif isinstance(value, models.Model):
final_dict[key] = sanitize_dict(model_to_dict(value))
elif isinstance(value, UUID):
final_dict[key] = value.hex
elif isinstance(value, (HttpRequest, WSGIRequest)):
continue
elif isinstance(value, City):
final_dict[key] = GEOIP_READER.city_to_dict(value)
elif isinstance(value, type):
final_dict[key] = {
"type": value.__name__,
"module": value.__module__,
}
else:
final_dict[key] = value
return final_dict

View File

@ -1,23 +1,24 @@
"""Flow API Views"""
from dataclasses import dataclass
from django.core.cache import cache
from django.http import HttpResponse
from django.http.response import HttpResponseBadRequest
from django.db.models import Model
from django.http.response import HttpResponseBadRequest, JsonResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.serializers import CharField, ModelSerializer, Serializer, SerializerMethodField
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.decorators import permission_required
from authentik.blueprints.v1.exporter import FlowExporter
from authentik.blueprints.v1.importer import Importer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import (
CacheSerializer,
@ -25,10 +26,12 @@ from authentik.core.api.utils import (
FileUploadSerializer,
LinkSerializer,
)
from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
from authentik.flows.transfer.common import DataclassEncoder
from authentik.flows.transfer.exporter import FlowExporter
from authentik.flows.transfer.importer import FlowImporter
from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
@ -77,6 +80,30 @@ class FlowSerializer(ModelSerializer):
}
class FlowDiagramSerializer(Serializer):
"""response of the flow's diagram action"""
diagram = CharField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
@dataclass
class DiagramElement:
"""Single element used in a diagram"""
identifier: str
type: str
rest: str
def __str__(self) -> str:
return f"{self.identifier}=>{self.type}: {self.rest}"
class FlowViewSet(UsedByMixin, ModelViewSet):
"""Flow Viewset"""
@ -136,13 +163,12 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
)
@action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
def import_flow(self, request: Request) -> Response:
"""Import flow from .yaml file"""
"""Import flow from .akflow file"""
file = request.FILES.get("file", None)
if not file:
return HttpResponseBadRequest()
importer = Importer(file.read().decode())
valid, _logs = importer.validate()
# TODO: return logs
importer = FlowImporter(file.read().decode())
valid = importer.validate()
if not valid:
return HttpResponseBadRequest()
successful = importer.apply()
@ -169,11 +195,11 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=unused-argument
def export(self, request: Request, slug: str) -> Response:
"""Export flow to .yaml file"""
"""Export flow to .akflow file"""
flow = self.get_object()
exporter = FlowExporter(flow)
response = HttpResponse(content=exporter.export_to_string())
response["Content-Disposition"] = f'attachment; filename="{flow.slug}.yaml"'
response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False)
response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"'
return response
@extend_schema(responses={200: FlowDiagramSerializer()})
@ -181,9 +207,84 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
# pylint: disable=unused-argument
def diagram(self, request: Request, slug: str) -> Response:
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
diagram = FlowDiagram(self.get_object(), request.user)
output = diagram.build()
return Response({"diagram": output})
flow = self.get_object()
header = [
DiagramElement("st", "start", "Start"),
]
body: list[DiagramElement] = []
footer = []
# Collect all elements we need
# First, policies bound to the flow itself
for p_index, policy_binding in enumerate(
get_objects_for_user(request.user, "authentik_policies.view_policybinding")
.filter(target=flow)
.exclude(policy__isnull=True)
.order_by("order")
):
body.append(
DiagramElement(
f"flow_policy_{p_index}",
"condition",
_("Policy (%(type)s)" % {"type": policy_binding.policy._meta.verbose_name})
+ "\n"
+ policy_binding.policy.name,
)
)
# Collect all stages
for s_index, stage_binding in enumerate(
get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding")
.filter(target=flow)
.order_by("order")
):
# First all policies bound to stages since they execute before stages
for p_index, policy_binding in enumerate(
get_objects_for_user(request.user, "authentik_policies.view_policybinding")
.filter(target=stage_binding)
.exclude(policy__isnull=True)
.order_by("order")
):
body.append(
DiagramElement(
f"stage_{s_index}_policy_{p_index}",
"condition",
_("Policy (%(type)s)" % {"type": policy_binding.policy._meta.verbose_name})
+ "\n"
+ policy_binding.policy.name,
)
)
body.append(
DiagramElement(
f"stage_{s_index}",
"operation",
_("Stage (%(type)s)" % {"type": stage_binding.stage._meta.verbose_name})
+ "\n"
+ stage_binding.stage.name,
)
)
# If the 2nd last element is a policy, we need to have an item to point to
# for a negative case
body.append(
DiagramElement("e", "end", "End|future"),
)
if len(body) == 1:
footer.append("st(right)->e")
else:
# Actual diagram flow
footer.append(f"st(right)->{body[0].identifier}")
for index in range(len(body) - 1):
element: DiagramElement = body[index]
if element.type == "condition":
# Policy passes, link policy yes to next stage
footer.append(f"{element.identifier}(yes, right)->{body[index + 1].identifier}")
# Policy doesn't pass, go to stage after next stage
no_element = body[index + 1]
if no_element.type != "end":
no_element = body[index + 2]
footer.append(f"{element.identifier}(no, bottom)->{no_element.identifier}")
elif element.type == "operation":
footer.append(f"{element.identifier}(bottom)->{body[index + 1].identifier}")
diagram = "\n".join([str(x) for x in header + body + footer])
return Response({"diagram": diagram})
@permission_required("authentik_flows.change_flow")
@extend_schema(

View File

@ -1,206 +0,0 @@
"""Flows Diagram API"""
from dataclasses import dataclass, field
from typing import Optional
from django.utils.translation import gettext as _
from guardian.shortcuts import get_objects_for_user
from rest_framework.serializers import CharField
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User
from authentik.flows.models import Flow, FlowStageBinding
@dataclass
class DiagramElement:
"""Single element used in a diagram"""
identifier: str
description: str
action: Optional[str] = None
source: Optional[list["DiagramElement"]] = None
style: list[str] = field(default_factory=lambda: ["[", "]"])
def __str__(self) -> str:
element = f'{self.identifier}{self.style[0]}"{self.description}"{self.style[1]}'
if self.action is not None:
if self.action != "":
element = f"--{self.action}--> {element}"
else:
element = f"--> {element}"
if self.source:
source_element = []
for source in self.source:
source_element.append(f"{source.identifier} {element}")
return "\n".join(source_element)
return element
class FlowDiagramSerializer(PassiveSerializer):
"""response of the flow's diagram action"""
diagram = CharField(read_only=True)
class FlowDiagram:
"""Generate flow chart fow a flow"""
flow: Flow
user: User
def __init__(self, flow: Flow, user: User) -> None:
self.flow = flow
self.user = user
def get_flow_policies(self, parent_elements: list[DiagramElement]) -> list[DiagramElement]:
"""Collect all policies bound to the flow"""
elements = []
for p_index, policy_binding in enumerate(
get_objects_for_user(self.user, "authentik_policies.view_policybinding")
.filter(target=self.flow)
.exclude(policy__isnull=True)
.order_by("order")
):
element = DiagramElement(
f"flow_policy_{p_index}",
_("Policy (%(type)s)" % {"type": policy_binding.policy._meta.verbose_name})
+ "\n"
+ policy_binding.policy.name,
_("Binding %(order)d" % {"order": policy_binding.order}),
parent_elements,
style=["{{", "}}"],
)
elements.append(element)
return elements
def get_stage_policies(
self,
stage_index: int,
stage_binding: FlowStageBinding,
parent_elements: list[DiagramElement],
) -> list[DiagramElement]:
"""First all policies bound to stages since they execute before stages"""
elements = []
for p_index, policy_binding in enumerate(
get_objects_for_user(self.user, "authentik_policies.view_policybinding")
.filter(target=stage_binding)
.exclude(policy__isnull=True)
.order_by("order")
):
element = DiagramElement(
f"stage_{stage_index}_policy_{p_index}",
_("Policy (%(type)s)" % {"type": policy_binding.policy._meta.verbose_name})
+ "\n"
+ policy_binding.policy.name,
"",
parent_elements,
style=["{{", "}}"],
)
elements.append(element)
return elements
def get_stages(self, parent_elements: list[DiagramElement]) -> list[str | DiagramElement]:
"""Collect all stages"""
elements = []
stages = []
for s_index, stage_binding in enumerate(
get_objects_for_user(self.user, "authentik_flows.view_flowstagebinding")
.filter(target=self.flow)
.order_by("order")
):
stage_policies = self.get_stage_policies(s_index, stage_binding, parent_elements)
elements.extend(stage_policies)
action = ""
if len(stage_policies) > 0:
action = _("Policy passed")
element = DiagramElement(
f"stage_{s_index}",
_("Stage (%(type)s)" % {"type": stage_binding.stage._meta.verbose_name})
+ "\n"
+ stage_binding.stage.name,
action,
stage_policies,
style=["([", "])"],
)
stages.append(element)
parent_elements = [element]
# This adds connections for policy denies, but retroactively, as we can't really
# look ahead
# Check if we have a stage behind us and if it has any sources
if s_index > 0:
last_stage: DiagramElement = stages[s_index - 1]
if last_stage.source and len(last_stage.source) > 0:
# If it has any sources, add a connection from each of that stage's sources
# to this stage
for source in last_stage.source:
elements.append(
DiagramElement(
element.identifier,
element.description,
_("Policy denied"),
[source],
style=element.style,
)
)
if len(stages) > 0:
elements.append(
DiagramElement(
"done",
_("End of the flow"),
"",
[stages[-1]],
style=["[[", "]]"],
),
)
return stages + elements
def build(self) -> str:
"""Build flowchart"""
all_elements = [
"graph TD",
]
pre_flow_policies_element = DiagramElement(
"flow_pre", _("Pre-flow policies"), style=["[[", "]]"]
)
flow_policies = self.get_flow_policies([pre_flow_policies_element])
if len(flow_policies) > 0:
all_elements.append(pre_flow_policies_element)
all_elements.extend(flow_policies)
all_elements.append(
DiagramElement(
"done",
_("End of the flow"),
_("Policy denied"),
flow_policies,
)
)
flow_element = DiagramElement(
"flow_start",
_("Flow") + "\n" + self.flow.name,
"" if len(flow_policies) > 0 else None,
source=flow_policies,
style=["[[", "]]"],
)
all_elements.append(flow_element)
stages = self.get_stages([flow_element])
all_elements.extend(stages)
if len(stages) < 1:
all_elements.append(
DiagramElement(
"done",
_("End of the flow"),
"",
[flow_element],
style=["[[", "]]"],
),
)
return "\n".join([str(x) for x in all_elements])

View File

@ -1,7 +1,10 @@
"""authentik flows app config"""
from importlib import import_module
from django.apps import AppConfig
from django.db.utils import ProgrammingError
from prometheus_client import Gauge, Histogram
from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.utils.reflection import all_subclasses
GAUGE_FLOWS_CACHED = Gauge(
@ -15,22 +18,20 @@ HIST_FLOWS_PLAN_TIME = Histogram(
)
class AuthentikFlowsConfig(ManagedAppConfig):
class AuthentikFlowsConfig(AppConfig):
"""authentik flows app config"""
name = "authentik.flows"
label = "authentik_flows"
mountpoint = "flows/"
verbose_name = "authentik Flows"
default = True
def reconcile_load_flows_signals(self):
"""Load flows signals"""
self.import_module("authentik.flows.signals")
def ready(self):
import_module("authentik.flows.signals")
try:
from authentik.flows.models import Stage
def reconcile_load_stages(self):
"""Ensure all stages are loaded"""
from authentik.flows.models import Stage
for stage in all_subclasses(Stage):
_ = stage().type
for stage in all_subclasses(Stage):
_ = stage().type
except ProgrammingError:
pass

View File

@ -1,16 +1,14 @@
"""Challenge helpers"""
from dataclasses import asdict, is_dataclass
from enum import Enum
from traceback import format_tb
from typing import TYPE_CHECKING, Optional, TypedDict
from uuid import UUID
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.http import JsonResponse
from rest_framework.fields import CharField, ChoiceField, DictField
from rest_framework.fields import ChoiceField, DictField
from rest_framework.serializers import CharField
from authentik.core.api.utils import PassiveSerializer
from authentik.flows.transfer.common import DataclassEncoder
if TYPE_CHECKING:
from authentik.flows.stage import StageView
@ -90,34 +88,6 @@ class WithUserInfoChallenge(Challenge):
pending_user_avatar = CharField()
class FlowErrorChallenge(WithUserInfoChallenge):
"""Challenge class when an unhandled error occurs during a stage. Normal users
are shown an error message, superusers are shown a full stacktrace."""
component = CharField(default="xak-flow-error")
request_id = CharField()
error = CharField(required=False)
traceback = CharField(required=False)
def __init__(self, *args, **kwargs):
request = kwargs.pop("request", None)
error = kwargs.pop("error", None)
super().__init__(*args, **kwargs)
if not request or not error:
return
self.request_id = request.request_id
from authentik.core.models import USER_ATTRIBUTE_DEBUG
if request.user and request.user.is_authenticated:
if request.user.is_superuser or request.user.group_attributes(request).get(
USER_ATTRIBUTE_DEBUG, False
):
self.error = error
self.traceback = "".join(format_tb(self.error.__traceback__))
class AccessDeniedChallenge(WithUserInfoChallenge):
"""Challenge when a flow's active stage calls `stage_invalid()`."""
@ -135,7 +105,7 @@ class PermissionDict(TypedDict):
class PermissionSerializer(PassiveSerializer):
"""Permission used for consent"""
name = CharField(allow_blank=True)
name = CharField()
id = CharField()
@ -165,19 +135,6 @@ class AutoSubmitChallengeResponse(ChallengeResponse):
component = CharField(default="ak-stage-autosubmit")
class DataclassEncoder(DjangoJSONEncoder):
"""Convert any dataclass to json"""
def default(self, o):
if is_dataclass(o):
return asdict(o)
if isinstance(o, UUID):
return str(o)
if isinstance(o, Enum):
return o.value
return super().default(o) # pragma: no cover
class HttpChallengeResponse(JsonResponse):
"""Subclass of JsonResponse that uses the `DataclassEncoder`"""

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