Skip to content

Commit

Permalink
blueprints: webui (goauthentik#3356)
Browse files Browse the repository at this point in the history
  • Loading branch information
BeryJu authored Aug 2, 2022
1 parent 20aeed1 commit d1004e3
Show file tree
Hide file tree
Showing 78 changed files with 921 additions and 195 deletions.
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,12 @@ ci-pending-migrations: ci--meta-debug

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
4 changes: 2 additions & 2 deletions authentik/admin/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""test admin api"""
from json import loads

from django.apps import apps
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
Expand Down Expand Up @@ -93,8 +93,8 @@ def test_apps(self):
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"""
apps.get_app_config("authentik_outposts").reconcile_embedded_outpost()
response = self.client.get(reverse("authentik_api:admin_system"))
self.assertEqual(response.status_code, 200)
8 changes: 5 additions & 3 deletions authentik/api/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Test API Authentication"""
from base64 import b64encode

from django.apps import apps
from django.conf import settings
from django.test import TestCase
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
Expand Down Expand Up @@ -42,9 +42,11 @@ def test_bearer_valid(self):
def test_managed_outpost(self):
"""Test managed outpost"""
with self.assertRaises(AuthenticationFailed):
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())

apps.get_app_config("authentik_outposts").reconcile_embedded_outpost()
@reconcile_app("authentik_outposts")
def test_managed_outpost_success(self):
"""Test managed outpost"""
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)

Expand Down
23 changes: 0 additions & 23 deletions authentik/blueprints/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +0,0 @@
"""Blueprint helpers"""
from functools import wraps
from typing import Callable


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:
with open(file, "r+", encoding="utf-8") as _file:
Importer(_file.read()).apply()
return func(*args, **kwargs)

return wrapper

return wrapper_outer
60 changes: 49 additions & 11 deletions authentik/blueprints/api.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
"""Serializer mixin for managed models"""
from glob import glob
from dataclasses import asdict

from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.fields import CharField, DateTimeField, JSONField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet

from authentik.api.decorators import permission_required
from authentik.blueprints.models import BlueprintInstance
from authentik.lib.config import CONFIG
from authentik.blueprints.v1.tasks import BlueprintFile, apply_blueprint, blueprints_find
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer


class ManagedSerializer:
Expand All @@ -20,6 +23,13 @@ class ManagedSerializer:
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"""

Expand All @@ -36,15 +46,18 @@ class Meta:
"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(ModelViewSet):
class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
"""Blueprint instances"""

permission_classes = [IsAdminUser]
Expand All @@ -53,12 +66,37 @@ class BlueprintInstanceViewSet(ModelViewSet):
search_fields = ["name", "path"]
filterset_fields = ["name", "path"]

@extend_schema(responses={200: ListSerializer(child=CharField())})
@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 = []
for folder in CONFIG.y("blueprint_locations"):
for file in glob(f"{folder}/**", recursive=True):
files.append(file)
return Response(files)
files: list[BlueprintFile] = blueprints_find.delay().get()
return Response([asdict(file) for file in 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)
9 changes: 7 additions & 2 deletions authentik/blueprints/management/commands/apply_blueprint.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"""Apply blueprint from commandline"""
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger

from authentik.blueprints.v1.importer import Importer

LOGGER = get_logger()

class Command(BaseCommand): # pragma: no cover

class Command(BaseCommand):
"""Apply blueprint from commandline"""

@no_translations
Expand All @@ -15,7 +18,9 @@ def handle(self, *args, **options):
importer = Importer(blueprint_file.read())
valid, logs = importer.validate()
if not valid:
raise ValueError(f"blueprint invalid: {logs}")
for log in logs:
LOGGER.debug(**log)
raise ValueError("blueprint invalid")
importer.apply()

def add_arguments(self, parser):
Expand Down
13 changes: 10 additions & 3 deletions authentik/blueprints/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@

from django.apps import AppConfig
from django.db import DatabaseError, InternalError, ProgrammingError
from structlog.stdlib import get_logger
from structlog.stdlib import BoundLogger, get_logger

LOGGER = 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()
Expand All @@ -31,7 +37,8 @@ def reconcile(self) -> None:
continue
name = meth_name.replace(prefix, "")
try:
self._logger.debug("Starting reconciler", name=name)
meth()
LOGGER.debug("Successfully reconciled", name=name)
self._logger.debug("Successfully reconciled", name=name)
except (DatabaseError, ProgrammingError, InternalError) as exc:
LOGGER.debug("Failed to run reconcile", name=name, exc=exc)
self._logger.debug("Failed to run reconcile", name=name, exc=exc)
25 changes: 18 additions & 7 deletions authentik/blueprints/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
from pathlib import Path

import django.contrib.postgres.fields
from dacite 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
Expand All @@ -15,24 +17,33 @@
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
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_EXAMPLE

with open(path, "r", encoding="utf-8") as blueprint_file:
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
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_EXAMPLE, "").lower() == "true":
return
if not instance:
instance = BlueprintInstance(
name=path.name,
name=meta.name if meta else str(rel_path),
path=str(path),
context={},
status=BlueprintInstanceStatus.UNKNOWN,
enabled=True,
managed_models=[],
last_applied_hash="",
metadata=metadata,
)
instance.save()

Expand All @@ -42,9 +53,8 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
Flow = apps.get_model("authentik_flows", "Flow")

db_alias = schema_editor.connection.alias
for folder in CONFIG.y("blueprint_locations"):
for file in glob(f"{folder}/**/*.yaml", recursive=True):
check_blueprint_v1_file(BlueprintInstance, Path(file))
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)
Expand Down Expand Up @@ -86,8 +96,9 @@ class Migration(migrations.Migration):
),
),
("name", models.TextField()),
("metadata", models.JSONField(default=dict)),
("path", models.TextField()),
("context", models.JSONField()),
("context", models.JSONField(default=dict)),
("last_applied", models.DateTimeField(auto_now=True)),
("last_applied_hash", models.TextField()),
(
Expand All @@ -106,7 +117,7 @@ class Migration(migrations.Migration):
(
"managed_models",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), size=None
base_field=models.TextField(), default=list, size=None
),
),
],
Expand Down
5 changes: 3 additions & 2 deletions authentik/blueprints/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
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()
context = models.JSONField(default=dict)
last_applied = models.DateTimeField(auto_now=True)
last_applied_hash = models.TextField()
status = models.TextField(choices=BlueprintInstanceStatus.choices)
enabled = models.BooleanField(default=True)
managed_models = ArrayField(models.TextField())
managed_models = ArrayField(models.TextField(), default=list)

@property
def serializer(self) -> Serializer:
Expand Down
45 changes: 45 additions & 0 deletions authentik/blueprints/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Blueprint helpers"""
from functools import wraps
from typing import Callable

from django.apps import apps

from authentik.blueprints.manager import ManagedAppConfig


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:
with open(file, "r+", encoding="utf-8") as _file:
Importer(_file.read()).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
Loading

0 comments on commit d1004e3

Please sign in to comment.