diff --git a/Makefile b/Makefile index 66fec97596..ef5175c26f 100644 --- a/Makefile +++ b/Makefile @@ -141,11 +141,7 @@ gen-build: ## Extract the schema from the database AUTHENTIK_DEBUG=true \ AUTHENTIK_TENANTS__ENABLED=true \ AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ - uv run ak make_blueprint_schema --file blueprints/schema.json - AUTHENTIK_DEBUG=true \ - AUTHENTIK_TENANTS__ENABLED=true \ - AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ - uv run ak spectacular --file schema.yml + uv run ak build_schema gen-compose: uv run scripts/generate_compose.py diff --git a/authentik/api/management/__init__.py b/authentik/api/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/api/management/commands/__init__.py b/authentik/api/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/api/management/commands/build_schema.py b/authentik/api/management/commands/build_schema.py new file mode 100644 index 0000000000..da4245a864 --- /dev/null +++ b/authentik/api/management/commands/build_schema.py @@ -0,0 +1,45 @@ +from json import dumps + +from django.core.management.base import BaseCommand, no_translations +from drf_spectacular.drainage import GENERATOR_STATS +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.renderers import OpenApiYamlRenderer +from drf_spectacular.validation import validate_schema +from structlog.stdlib import get_logger + +from authentik.blueprints.v1.schema import SchemaBuilder + + +class Command(BaseCommand): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = get_logger() + + def add_arguments(self, parser): + parser.add_argument("--blueprint-file", type=str, default="blueprints/schema.json") + parser.add_argument("--api-file", type=str, default="schema.yml") + + @no_translations + def handle(self, *args, blueprint_file: str, api_file: str, **options): + self.build_blueprint(blueprint_file) + self.build_api(api_file) + + def build_blueprint(self, file: str): + self.logger.debug("Building blueprint schema...", file=file) + blueprint_builder = SchemaBuilder() + blueprint_builder.build() + with open(file, "w") as _schema: + _schema.write( + dumps(blueprint_builder.schema, indent=4, default=SchemaBuilder.json_default) + ) + + def build_api(self, file: str): + self.logger.debug("Building API schema...", file=file) + generator = SchemaGenerator() + schema = generator.get_schema(request=None, public=True) + GENERATOR_STATS.emit_summary() + validate_schema(schema) + output = OpenApiYamlRenderer().render(schema, renderer_context={}) + with open(file, "wb") as f: + f.write(output) diff --git a/authentik/api/tests/test_schema.py b/authentik/api/tests/test_schema.py index e33a31f9df..84edb2f242 100644 --- a/authentik/api/tests/test_schema.py +++ b/authentik/api/tests/test_schema.py @@ -1,9 +1,14 @@ """Schema generation tests""" +from pathlib import Path + +from django.core.management import call_command from django.urls import reverse from rest_framework.test import APITestCase from yaml import safe_load +from authentik.lib.config import CONFIG + class TestSchemaGeneration(APITestCase): """Generic admin tests""" @@ -21,3 +26,18 @@ class TestSchemaGeneration(APITestCase): reverse("authentik_api:schema-browser"), ) self.assertEqual(response.status_code, 200) + + def test_build_schema(self): + """Test schema build command""" + blueprint_file = Path("blueprints/schema.json") + api_file = Path("schema.yml") + blueprint_file.unlink() + api_file.unlink() + with ( + CONFIG.patch("debug", True), + CONFIG.patch("tenants.enabled", True), + CONFIG.patch("outposts.disable_embedded_outpost", True), + ): + call_command("build_schema") + self.assertTrue(blueprint_file.exists()) + self.assertTrue(api_file.exists()) diff --git a/authentik/blueprints/management/commands/make_blueprint_schema.py b/authentik/blueprints/v1/schema.py similarity index 93% rename from authentik/blueprints/management/commands/make_blueprint_schema.py rename to authentik/blueprints/v1/schema.py index 0227726595..d739a594a1 100644 --- a/authentik/blueprints/management/commands/make_blueprint_schema.py +++ b/authentik/blueprints/v1/schema.py @@ -1,9 +1,7 @@ """Generate JSON Schema for blueprints""" -from json import dumps from typing import Any -from django.core.management.base import BaseCommand, no_translations from django.db.models import Model, fields from django.db.models.fields.related import OneToOneField from drf_jsonschema_serializer.convert import converter, field_to_converter @@ -40,13 +38,12 @@ class PrimaryKeyRelatedFieldConverter: return {"type": "integer"} -class Command(BaseCommand): +class SchemaBuilder: """Generate JSON Schema for blueprints""" schema: dict - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self): self.schema = { "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://goauthentik.io/blueprints/schema.json", @@ -93,16 +90,6 @@ class Command(BaseCommand): "$defs": {"blueprint_entry": {"oneOf": []}}, } - def add_arguments(self, parser): - parser.add_argument("--file", type=str) - - @no_translations - def handle(self, *args, file: str, **options): - """Generate JSON Schema for blueprints""" - self.build() - with open(file, "w") as _schema: - _schema.write(dumps(self.schema, indent=4, default=Command.json_default)) - @staticmethod def json_default(value: Any) -> Any: """Helper that handles gettext_lazy strings that JSON doesn't handle""" @@ -124,7 +111,7 @@ class Command(BaseCommand): try: serializer_class = model_instance.serializer except NotImplementedError as exc: - raise NotImplementedError(model_instance) from exc + raise ValueError(f"SerializerModel not implemented by {model}") from exc serializer = serializer_class( context={ SERIALIZER_CONTEXT_BLUEPRINT: False, diff --git a/authentik/core/tests/test_source_property_mappings.py b/authentik/core/tests/test_source_property_mappings.py index 0844e98c2c..0ace58f449 100644 --- a/authentik/core/tests/test_source_property_mappings.py +++ b/authentik/core/tests/test_source_property_mappings.py @@ -5,9 +5,10 @@ from django.test import TestCase from authentik.core.models import Group, PropertyMapping, Source, User from authentik.core.sources.mapper import SourceMapper from authentik.lib.generators import generate_id +from authentik.lib.models import InternallyManagedMixin -class ProxySource(Source): +class ProxySource(InternallyManagedMixin, Source): @property def property_mapping_type(self): return PropertyMapping diff --git a/authentik/policies/event_matcher/models.py b/authentik/policies/event_matcher/models.py index 629258614a..223b428995 100644 --- a/authentik/policies/event_matcher/models.py +++ b/authentik/policies/event_matcher/models.py @@ -9,6 +9,7 @@ from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger from authentik.blueprints.v1.importer import is_model_allowed +from authentik.blueprints.v1.meta.registry import BaseMetaModel from authentik.events.models import Event, EventAction from authentik.policies.models import Policy from authentik.policies.types import PolicyRequest, PolicyResult @@ -31,7 +32,7 @@ def model_choices() -> list[tuple[str, str]]: Returns a list of tuples containing (dotted.model.path, name)""" choices = [] for model in apps.get_models(): - if not is_model_allowed(model): + if not is_model_allowed(model) or issubclass(model, BaseMetaModel): continue name = f"{model._meta.app_label}.{model._meta.model_name}" choices.append((name, model._meta.verbose_name))