From c6ee7b68819fe02de92808e1850a135dca5cab36 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Fri, 24 Apr 2026 13:47:05 +0100 Subject: [PATCH] core: complete rework to oobe and setup experience (#21753) * initial Signed-off-by: Jens Langhammer * use same startup template Signed-off-by: Jens Langhammer * fix check not working Signed-off-by: Jens Langhammer * unrelated: fix inspector auth Signed-off-by: Jens Langhammer * add tests Signed-off-by: Jens Langhammer * update docs Signed-off-by: Jens Langhammer * ensure oobe flow can only accessed via correct url Signed-off-by: Jens Langhammer * set setup flag when applying bootstrap blueprint when env is set Signed-off-by: Jens Langhammer * add system visibility to flags to make them non-editable Signed-off-by: Jens Langhammer * set setup flag for e2e tests Signed-off-by: Jens Langhammer * fix tests and linting Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * make github lint happy Signed-off-by: Jens Langhammer * make tests have less assumptions Signed-off-by: Jens Langhammer * Update docs * include more heuristics in migration Signed-off-by: Jens Langhammer * add management command to set any flag Signed-off-by: Jens Langhammer * migrate worker command to signal Signed-off-by: Jens Langhammer * improved api for setting flags Signed-off-by: Jens Langhammer * short circuit Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer Co-authored-by: Dewi Roberts --- .../management/commands/apply_blueprint.py | 3 +- authentik/core/apps.py | 10 ++ authentik/core/migrations/0058_setup.py | 61 +++++++ authentik/core/setup/__init__.py | 0 authentik/core/setup/signals.py | 38 +++++ authentik/core/setup/views.py | 80 +++++++++ authentik/core/tests/test_interface_views.py | 2 + authentik/core/tests/test_setup.py | 156 ++++++++++++++++++ authentik/core/urls.py | 9 +- authentik/core/views/interface.py | 8 +- authentik/flows/views/inspector.py | 6 +- .../management/commands/create_admin_group.py | 4 +- authentik/tenants/api/settings.py | 31 +++- authentik/tenants/flags.py | 42 ++++- .../tenants/management/commands/set_flag.py | 19 +++ .../tenants/tests/test_local_settings.py | 57 ++++++- blueprints/default/flow-oobe.yaml | 29 ++-- lifecycle/ak | 6 - tests/live.py | 2 + .../setup/frontend-dev-environment.md | 2 - .../setup/full-dev-environment.mdx | 2 +- website/docs/install-config/install/aws.md | 8 +- .../install-config/install/docker-compose.mdx | 10 +- .../docs/install-config/install/kubernetes.md | 8 +- 24 files changed, 526 insertions(+), 67 deletions(-) create mode 100644 authentik/core/migrations/0058_setup.py create mode 100644 authentik/core/setup/__init__.py create mode 100644 authentik/core/setup/signals.py create mode 100644 authentik/core/setup/views.py create mode 100644 authentik/core/tests/test_setup.py create mode 100644 authentik/tenants/management/commands/set_flag.py diff --git a/authentik/blueprints/management/commands/apply_blueprint.py b/authentik/blueprints/management/commands/apply_blueprint.py index b996c5ebae..3563e2d7f9 100644 --- a/authentik/blueprints/management/commands/apply_blueprint.py +++ b/authentik/blueprints/management/commands/apply_blueprint.py @@ -1,5 +1,6 @@ """Apply blueprint from commandline""" +from argparse import ArgumentParser from sys import exit as sys_exit from django.core.management.base import BaseCommand, no_translations @@ -31,5 +32,5 @@ class Command(BaseCommand): sys_exit(1) importer.apply() - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser): parser.add_argument("blueprints", nargs="+", type=str) diff --git a/authentik/core/apps.py b/authentik/core/apps.py index 558334a0cc..9693eeaf89 100644 --- a/authentik/core/apps.py +++ b/authentik/core/apps.py @@ -7,6 +7,12 @@ from authentik.tasks.schedules.common import ScheduleSpec from authentik.tenants.flags import Flag +class Setup(Flag[bool], key="setup"): + + default = False + visibility = "system" + + class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"): default = True @@ -26,6 +32,10 @@ class AuthentikCoreConfig(ManagedAppConfig): mountpoint = "" default = True + def import_related(self): + super().import_related() + self.import_module("authentik.core.setup.signals") + @ManagedAppConfig.reconcile_tenant def source_inbuilt(self): """Reconcile inbuilt source""" diff --git a/authentik/core/migrations/0058_setup.py b/authentik/core/migrations/0058_setup.py new file mode 100644 index 0000000000..07009cc3e0 --- /dev/null +++ b/authentik/core/migrations/0058_setup.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.13 on 2026-04-21 18:49 +from django.apps.registry import Apps + +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from django.db import migrations + + +def check_is_already_setup(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + from django.conf import settings + from authentik.flows.models import FlowAuthenticationRequirement + + VersionHistory = apps.get_model("authentik_admin", "VersionHistory") + Flow = apps.get_model("authentik_flows", "Flow") + User = apps.get_model("authentik_core", "User") + + db_alias = schema_editor.connection.alias + + # Upgrading from a previous version + if not settings.TEST and VersionHistory.objects.using(db_alias).count() > 1: + return True + # OOBE flow sets itself to this authentication requirement once finished + if ( + Flow.objects.using(db_alias) + .filter( + slug="initial-setup", authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER + ) + .exists() + ): + return True + # non-akadmin and non-guardian anonymous user exist + if ( + User.objects.using(db_alias) + .exclude(username="akadmin") + .exclude(username="AnonymousUser") + .exists() + ): + return True + return False + + +def update_setup_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + from authentik.core.apps import Setup + from authentik.tenants.utils import get_current_tenant + + is_already_setup = check_is_already_setup(apps, schema_editor) + if is_already_setup: + tenant = get_current_tenant() + tenant.flags[Setup().key] = True + tenant.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"), + # 0024_flow_authentication adds the `authentication` field. + ("authentik_flows", "0024_flow_authentication"), + ] + + operations = [migrations.RunPython(update_setup_flag, migrations.RunPython.noop)] diff --git a/authentik/core/setup/__init__.py b/authentik/core/setup/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/core/setup/signals.py b/authentik/core/setup/signals.py new file mode 100644 index 0000000000..8d9ec68614 --- /dev/null +++ b/authentik/core/setup/signals.py @@ -0,0 +1,38 @@ +from os import getenv + +from django.dispatch import receiver +from structlog.stdlib import get_logger + +from authentik.blueprints.models import BlueprintInstance +from authentik.blueprints.v1.importer import Importer +from authentik.core.apps import Setup +from authentik.root.signals import post_startup +from authentik.tenants.models import Tenant + +BOOTSTRAP_BLUEPRINT = "system/bootstrap.yaml" + +LOGGER = get_logger() + + +@receiver(post_startup) +def post_startup_setup_bootstrap(sender, **_): + if not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN"): + return + LOGGER.info("Configuring authentik through bootstrap environment variables") + content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve() + # If we have bootstrap credentials set, run bootstrap tasks outside of main server + # sync, so that we can sure the first start actually has working bootstrap + # credentials + for tenant in Tenant.objects.filter(ready=True): + if Setup.get(tenant=tenant): + LOGGER.info("Tenant is already setup, skipping", tenant=tenant.schema_name) + continue + with tenant: + importer = Importer.from_string(content) + valid, logs = importer.validate() + if not valid: + LOGGER.warning("Blueprint invalid", tenant=tenant.schema_name) + for log in logs: + log.log() + importer.apply() + Setup.set(True, tenant=tenant) diff --git a/authentik/core/setup/views.py b/authentik/core/setup/views.py new file mode 100644 index 0000000000..a61c5b6e83 --- /dev/null +++ b/authentik/core/setup/views.py @@ -0,0 +1,80 @@ +from functools import lru_cache +from http import HTTPMethod, HTTPStatus + +from django.contrib.staticfiles import finders +from django.db import transaction +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.views import View +from structlog.stdlib import get_logger + +from authentik.blueprints.models import BlueprintInstance +from authentik.core.apps import Setup +from authentik.flows.models import Flow, FlowAuthenticationRequirement, in_memory_stage +from authentik.flows.planner import FlowPlanner +from authentik.flows.stage import StageView + +LOGGER = get_logger() +FLOW_CONTEXT_START_BY = "goauthentik.io/core/setup/started-by" + + +@lru_cache +def read_static(path: str) -> str | None: + result = finders.find(path) + if not result: + return None + with open(result, encoding="utf8") as _file: + return _file.read() + + +class SetupView(View): + + setup_flow_slug = "initial-setup" + + def dispatch(self, request: HttpRequest, *args, **kwargs): + if request.method != HTTPMethod.HEAD and Setup.get(): + return redirect(reverse("authentik_core:root-redirect")) + return super().dispatch(request, *args, **kwargs) + + def head(self, request: HttpRequest, *args, **kwargs): + if Setup.get(): + return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE) + if not Flow.objects.filter(slug=self.setup_flow_slug).exists(): + return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE) + return HttpResponse(status=HTTPStatus.OK) + + def get(self, request: HttpRequest): + flow = Flow.objects.filter(slug=self.setup_flow_slug).first() + if not flow: + LOGGER.info("Setup flow does not exist yet, waiting for worker to finish") + return HttpResponse( + read_static("dist/standalone/loading/startup.html"), + status=HTTPStatus.SERVICE_UNAVAILABLE, + ) + planner = FlowPlanner(flow) + plan = planner.plan(request, {FLOW_CONTEXT_START_BY: "setup"}) + plan.append_stage(in_memory_stage(PostSetupStageView)) + return plan.to_redirect(request, flow) + + +class PostSetupStageView(StageView): + """Run post-setup tasks""" + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Wrapper when this stage gets hit with a post request""" + return self.get(request, *args, **kwargs) + + def get(self, requeset: HttpRequest, *args, **kwargs): + with transaction.atomic(): + # Remember we're setup + Setup.set(True) + # Disable OOBE Blueprints + BlueprintInstance.objects.filter( + **{"metadata__labels__blueprints.goauthentik.io/system-oobe": "true"} + ).update(enabled=False) + # Make flow inaccessible + Flow.objects.filter(slug="initial-setup").update( + authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER + ) + return self.executor.stage_ok() diff --git a/authentik/core/tests/test_interface_views.py b/authentik/core/tests/test_interface_views.py index 00bb215ed3..86bd23631e 100644 --- a/authentik/core/tests/test_interface_views.py +++ b/authentik/core/tests/test_interface_views.py @@ -4,6 +4,7 @@ from django.test import TestCase from django.urls import reverse from authentik.brands.models import Brand +from authentik.core.apps import Setup from authentik.core.models import Application, UserTypes from authentik.core.tests.utils import create_test_brand, create_test_user @@ -12,6 +13,7 @@ class TestInterfaceRedirects(TestCase): """Test RootRedirectView and BrandDefaultRedirectView redirect logic by user type""" def setUp(self): + Setup.set(True) self.app = Application.objects.create(name="test-app", slug="test-app") self.brand: Brand = create_test_brand(default_application=self.app) diff --git a/authentik/core/tests/test_setup.py b/authentik/core/tests/test_setup.py new file mode 100644 index 0000000000..30fbe415e4 --- /dev/null +++ b/authentik/core/tests/test_setup.py @@ -0,0 +1,156 @@ +from http import HTTPStatus +from os import environ + +from django.urls import reverse + +from authentik.blueprints.tests import apply_blueprint +from authentik.core.apps import Setup +from authentik.core.models import Token, TokenIntents, User +from authentik.flows.models import Flow +from authentik.flows.tests import FlowTestCase +from authentik.lib.generators import generate_id +from authentik.root.signals import post_startup, pre_startup +from authentik.tenants.flags import patch_flag + + +class TestSetup(FlowTestCase): + def tearDown(self): + environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None) + environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None) + + @patch_flag(Setup, True) + def test_setup(self): + """Test existing instance""" + res = self.client.get(reverse("authentik_core:root-redirect")) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + self.assertRedirects( + res, + reverse("authentik_flows:default-authentication") + "?next=/", + fetch_redirect_response=False, + ) + + res = self.client.head(reverse("authentik_core:setup")) + self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE) + + res = self.client.get(reverse("authentik_core:setup")) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + self.assertRedirects( + res, + reverse("authentik_core:root-redirect"), + fetch_redirect_response=False, + ) + + @patch_flag(Setup, False) + def test_not_setup_no_flow(self): + """Test case on initial startup; setup flag is not set and oobe flow does + not exist yet""" + Flow.objects.filter(slug="initial-setup").delete() + res = self.client.get(reverse("authentik_core:root-redirect")) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False) + # Flow does not exist, hence 503 + res = self.client.get(reverse("authentik_core:setup")) + self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE) + res = self.client.head(reverse("authentik_core:setup")) + self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE) + + @patch_flag(Setup, False) + @apply_blueprint("default/flow-oobe.yaml") + def test_not_setup(self): + """Test case for when worker comes up, and has created flow""" + res = self.client.get(reverse("authentik_core:root-redirect")) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False) + # Flow does not exist, hence 503 + res = self.client.head(reverse("authentik_core:setup")) + self.assertEqual(res.status_code, HTTPStatus.OK) + res = self.client.get(reverse("authentik_core:setup")) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + self.assertRedirects( + res, + reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}), + fetch_redirect_response=False, + ) + + @apply_blueprint("default/flow-oobe.yaml") + @apply_blueprint("system/bootstrap.yaml") + def test_setup_flow_full(self): + """Test full setup flow""" + Setup.set(False) + + res = self.client.get(reverse("authentik_core:setup")) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + self.assertRedirects( + res, + reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}), + fetch_redirect_response=False, + ) + + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}), + ) + self.assertEqual(res.status_code, HTTPStatus.OK) + self.assertStageResponse(res, component="ak-stage-prompt") + + pw = generate_id() + res = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}), + { + "email": f"{generate_id()}@t.goauthentik.io", + "password": pw, + "password_repeat": pw, + "component": "ak-stage-prompt", + }, + ) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}), + ) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}), + ) + self.assertEqual(res.status_code, HTTPStatus.FOUND) + + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}), + ) + self.assertEqual(res.status_code, HTTPStatus.OK) + + self.assertTrue(Setup.get()) + user = User.objects.get(username="akadmin") + self.assertTrue(user.check_password(pw)) + + @patch_flag(Setup, False) + @apply_blueprint("default/flow-oobe.yaml") + @apply_blueprint("system/bootstrap.yaml") + def test_setup_flow_direct(self): + """Test setup flow, directly accessing the flow""" + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}) + ) + self.assertStageResponse( + res, + component="ak-stage-access-denied", + error_message="Access the authentik setup by navigating to http://testserver/", + ) + + def test_setup_bootstrap_env(self): + """Test setup with env vars""" + User.objects.filter(username="akadmin").delete() + Setup.set(False) + + environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] = generate_id() + environ["AUTHENTIK_BOOTSTRAP_TOKEN"] = generate_id() + pre_startup.send(sender=self) + post_startup.send(sender=self) + + self.assertTrue(Setup.get()) + user = User.objects.get(username="akadmin") + self.assertTrue(user.check_password(environ["AUTHENTIK_BOOTSTRAP_PASSWORD"])) + + token = Token.objects.filter(identifier="authentik-bootstrap-token").first() + self.assertEqual(token.intent, TokenIntents.INTENT_API) + self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"]) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index 7a97c1f379..704ddacc85 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -1,7 +1,6 @@ """authentik URL Configuration""" from django.conf import settings -from django.contrib.auth.decorators import login_required from django.urls import path from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet @@ -19,6 +18,7 @@ from authentik.core.api.sources import ( from authentik.core.api.tokens import TokenViewSet from authentik.core.api.transactional_applications import TransactionalApplicationView from authentik.core.api.users import UserViewSet +from authentik.core.setup.views import SetupView from authentik.core.views.apps import RedirectToAppLaunch from authentik.core.views.debug import AccessDeniedView from authentik.core.views.interface import ( @@ -35,7 +35,7 @@ from authentik.tenants.channels import TenantsAwareMiddleware urlpatterns = [ path( "", - login_required(RootRedirectView.as_view()), + RootRedirectView.as_view(), name="root-redirect", ), path( @@ -62,6 +62,11 @@ urlpatterns = [ FlowInterfaceView.as_view(), name="if-flow", ), + path( + "setup", + SetupView.as_view(), + name="setup", + ), # Fallback for WS path("ws/outpost//", InterfaceView.as_view(template_name="if/admin.html")), path( diff --git a/authentik/core/views/interface.py b/authentik/core/views/interface.py index ac0c6893fd..6751ddac47 100644 --- a/authentik/core/views/interface.py +++ b/authentik/core/views/interface.py @@ -3,6 +3,7 @@ from json import dumps from typing import Any +from django.contrib.auth.mixins import AccessMixin from django.http import HttpRequest from django.http.response import HttpResponse from django.shortcuts import redirect @@ -14,12 +15,13 @@ from authentik.admin.tasks import LOCAL_VERSION from authentik.api.v3.config import ConfigView from authentik.brands.api import CurrentBrandSerializer from authentik.brands.models import Brand +from authentik.core.apps import Setup from authentik.core.models import UserTypes from authentik.lib.config import CONFIG from authentik.policies.denied import AccessDeniedResponse -class RootRedirectView(RedirectView): +class RootRedirectView(AccessMixin, RedirectView): """Root redirect view, redirect to brand's default application if set""" pattern_name = "authentik_core:if-user" @@ -40,6 +42,10 @@ class RootRedirectView(RedirectView): return None def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if not Setup.get(): + return redirect("authentik_core:setup") + if not request.user.is_authenticated: + return self.handle_no_permission() if redirect_response := RootRedirectView().redirect_to_app(request): return redirect_response return super().dispatch(request, *args, **kwargs) diff --git a/authentik/flows/views/inspector.py b/authentik/flows/views/inspector.py index d252bce2db..8a196a14d2 100644 --- a/authentik/flows/views/inspector.py +++ b/authentik/flows/views/inspector.py @@ -71,7 +71,11 @@ class FlowInspectorView(APIView): flow: Flow _logger: BoundLogger - permission_classes = [IsAuthenticated] + + def get_permissions(self): + if settings.DEBUG: + return [] + return [IsAuthenticated()] def setup(self, request: HttpRequest, flow_slug: str): super().setup(request, flow_slug=flow_slug) diff --git a/authentik/recovery/management/commands/create_admin_group.py b/authentik/recovery/management/commands/create_admin_group.py index 979717877e..e5720e7a55 100644 --- a/authentik/recovery/management/commands/create_admin_group.py +++ b/authentik/recovery/management/commands/create_admin_group.py @@ -1,5 +1,7 @@ """authentik recovery create_admin_group""" +from argparse import ArgumentParser + from django.utils.translation import gettext as _ from authentik.core.models import User @@ -12,7 +14,7 @@ class Command(TenantCommand): help = _("Create admin group if the default group gets deleted.") - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser): parser.add_argument("user", action="store", help="User to add to the admin group.") def handle_per_tenant(self, *args, **options): diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index 94dd882b17..8ba2720609 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -19,19 +19,32 @@ from authentik.tenants.models import Tenant class FlagJSONField(JSONDictField): + def to_representation(self, value: dict) -> dict: + """Exclude any system flags that aren't modifiable""" + new_value = value.copy() + for flag in Flag.available(exclude_system=False): + _flag = flag() + if _flag.visibility == "system": + new_value.pop(_flag.key, None) + return super().to_representation(new_value) + def run_validators(self, value: dict): super().run_validators(value) - for flag in Flag.available(): + for flag in Flag.available(exclude_system=False): _flag = flag() - if _flag.key in value: - flag_value = value.get(_flag.key) - flag_type = get_args(_flag.__orig_bases__[0])[0] - if flag_value and not isinstance(flag_value, flag_type): - raise ValidationError( - _("Value for flag {flag_key} needs to be of type {type}.").format( - flag_key=_flag.key, type=flag_type.__name__ - ) + if _flag.key not in value: + continue + if _flag.visibility == "system": + value.pop(_flag.key, None) + continue + flag_value = value.get(_flag.key) + flag_type = get_args(_flag.__orig_bases__[0])[0] + if flag_value and not isinstance(flag_value, flag_type): + raise ValidationError( + _("Value for flag {flag_key} needs to be of type {type}.").format( + flag_key=_flag.key, type=flag_type.__name__ ) + ) class FlagsJSONExtension(OpenApiSerializerFieldExtension): diff --git a/authentik/tenants/flags.py b/authentik/tenants/flags.py index 973ea1b6bf..69c53c9c9c 100644 --- a/authentik/tenants/flags.py +++ b/authentik/tenants/flags.py @@ -4,6 +4,7 @@ from functools import wraps from typing import TYPE_CHECKING, Any, Literal from django.db import DatabaseError, InternalError, ProgrammingError +from django.db.models import F, Func, JSONField, Value from authentik.lib.utils.reflection import all_subclasses @@ -13,7 +14,9 @@ if TYPE_CHECKING: class Flag[T]: default: T | None = None - visibility: Literal["none"] | Literal["public"] | Literal["authenticated"] = "none" + visibility: ( + Literal["none"] | Literal["public"] | Literal["authenticated"] | Literal["system"] + ) = "none" description: str | None = None def __init_subclass__(cls, key: str, **kwargs): @@ -24,12 +27,15 @@ class Flag[T]: return self.__key @classmethod - def get(cls) -> T | None: + def get(cls, tenant: Tenant | None = None) -> T | None: from authentik.tenants.utils import get_current_tenant + if not tenant: + tenant = get_current_tenant(["flags"]) + flags = {} try: - flags: dict[str, Any] = get_current_tenant(["flags"]).flags + flags: dict[str, Any] = tenant.flags except DatabaseError, ProgrammingError, InternalError: pass value = flags.get(cls.__key, None) @@ -37,20 +43,38 @@ class Flag[T]: return cls().get_default() return value + @classmethod + def set(cls, value: T, tenant: Tenant | None = None) -> T | None: + from authentik.tenants.models import Tenant + from authentik.tenants.utils import get_current_tenant + + if not tenant: + tenant = get_current_tenant() + + Tenant.objects.filter(pk=tenant.pk).update( + flags=Func( + F("flags"), + Value([cls.__key]), + Value(value, JSONField()), + function="jsonb_set", + ) + ) + def get_default(self) -> T | None: return self.default @staticmethod def available( visibility: Literal["none"] | Literal["public"] | Literal["authenticated"] | None = None, + exclude_system=True, ): flags = all_subclasses(Flag) - if visibility: - for flag in flags: - if flag.visibility == visibility: - yield flag - else: - yield from flags + for flag in flags: + if visibility and flag.visibility != visibility: + continue + if exclude_system and flag.visibility == "system": + continue + yield flag def patch_flag[T](flag: Flag[T], value: T): diff --git a/authentik/tenants/management/commands/set_flag.py b/authentik/tenants/management/commands/set_flag.py new file mode 100644 index 0000000000..8a45796361 --- /dev/null +++ b/authentik/tenants/management/commands/set_flag.py @@ -0,0 +1,19 @@ +from argparse import ArgumentParser +from typing import Any + +from authentik.tenants.management import TenantCommand +from authentik.tenants.utils import get_current_tenant + + +class Command(TenantCommand): + + def add_arguments(self, parser: ArgumentParser): + parser.add_argument("flag_key", type=str) + parser.add_argument("flag_value", type=str) + + def handle(self, *, flag_key: str, flag_value: Any, **options): + tenant = get_current_tenant() + val = flag_value.lower() == "true" + tenant.flags[flag_key] = val + tenant.save() + self.stdout.write(f"Set flag '{flag_key}' to {val}.") diff --git a/authentik/tenants/tests/test_local_settings.py b/authentik/tenants/tests/test_local_settings.py index 3da22fb09c..2943d5a898 100644 --- a/authentik/tenants/tests/test_local_settings.py +++ b/authentik/tenants/tests/test_local_settings.py @@ -1,10 +1,12 @@ """Test Settings API""" +from django.core.management import call_command from django.urls import reverse from rest_framework.test import APITestCase from authentik.core.tests.utils import create_test_admin_user from authentik.tenants.flags import Flag +from authentik.tenants.utils import get_current_tenant class TestLocalSettingsAPI(APITestCase): @@ -13,11 +15,19 @@ class TestLocalSettingsAPI(APITestCase): def setUp(self): super().setUp() self.local_admin = create_test_admin_user() + self.tenant = get_current_tenant() + + def tearDown(self): + super().tearDown() + self.tenant.flags = {} + self.tenant.save() def test_settings_flags(self): """Test settings API""" + self.tenant.flags = {} + self.tenant.save() - class TestFlag(Flag[bool], key="tenants_test_flag"): + class _TestFlag(Flag[bool], key="tenants_test_flag_bool"): default = False visibility = "public" @@ -26,15 +36,19 @@ class TestLocalSettingsAPI(APITestCase): response = self.client.patch( reverse("authentik_api:tenant_settings"), data={ - "flags": {"tenants_test_flag": True}, + "flags": {"tenants_test_flag_bool": True}, }, ) self.assertEqual(response.status_code, 200) + self.tenant.refresh_from_db() + self.assertEqual(self.tenant.flags["tenants_test_flag_bool"], True) def test_settings_flags_incorrect(self): """Test settings API""" + self.tenant.flags = {} + self.tenant.save() - class TestFlag(Flag[bool], key="tenants_test_flag"): + class _TestFlag(Flag[bool], key="tenants_test_flag_incorrect"): default = False visibility = "public" @@ -43,11 +57,44 @@ class TestLocalSettingsAPI(APITestCase): response = self.client.patch( reverse("authentik_api:tenant_settings"), data={ - "flags": {"tenants_test_flag": 123}, + "flags": {"tenants_test_flag_incorrect": 123}, }, ) self.assertEqual(response.status_code, 400) self.assertJSONEqual( response.content, - {"flags": ["Value for flag tenants_test_flag needs to be of type bool."]}, + {"flags": ["Value for flag tenants_test_flag_incorrect needs to be of type bool."]}, ) + self.tenant.refresh_from_db() + self.assertEqual(self.tenant.flags, {}) + + def test_settings_flags_system(self): + """Test settings API""" + self.tenant.flags = {} + self.tenant.save() + + class _TestFlag(Flag[bool], key="tenants_test_flag_sys"): + + default = False + visibility = "system" + + self.client.force_login(self.local_admin) + response = self.client.patch( + reverse("authentik_api:tenant_settings"), + data={ + "flags": {"tenants_test_flag_sys": 123}, + }, + ) + print(response.content) + self.assertEqual(response.status_code, 200) + self.tenant.refresh_from_db() + self.assertEqual(self.tenant.flags, {}) + + def test_command(self): + self.tenant.flags = {} + self.tenant.save() + + call_command("set_flag", "foo", "true") + + self.tenant.refresh_from_db() + self.assertTrue(self.tenant.flags["foo"]) diff --git a/blueprints/default/flow-oobe.yaml b/blueprints/default/flow-oobe.yaml index 5264cb7d9b..0b887e35a3 100644 --- a/blueprints/default/flow-oobe.yaml +++ b/blueprints/default/flow-oobe.yaml @@ -1,4 +1,7 @@ metadata: + labels: + blueprints.goauthentik.io/system-oobe: "true" + blueprints.goauthentik.io/system: "true" name: Default - Out-of-box-experience flow version: 1 entries: @@ -75,23 +78,20 @@ entries: - attrs: expression: | # This policy ensures that the setup flow can only be - # executed when the admin user doesn''t have a password set + # executed when the admin user doesn't have a password set akadmin = ak_user_by(username="akadmin") - return not akadmin.has_usable_password() + # Ensure flow was started correctly + started_by = context.get("goauthentik.io/core/setup/started-by") + if started_by != "setup": + setup_url = request.http_request.build_absolute_uri("/") + ak_message(f"Access the authentik setup by navigating to {setup_url}") + return False + return akadmin is None or not akadmin.has_usable_password() id: policy-default-oobe-password-usable identifiers: name: default-oobe-password-usable model: authentik_policies_expression.expressionpolicy -- attrs: - expression: | - # This policy ensures that the setup flow can only be - # used one time - from authentik.flows.models import Flow, FlowAuthenticationRequirement - Flow.objects.filter(slug="initial-setup").update( - authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER, - ) - return True - id: policy-default-oobe-flow-set-authentication +- state: absent identifiers: name: default-oobe-flow-set-authentication model: authentik_policies_expression.expressionpolicy @@ -154,8 +154,3 @@ entries: policy: !KeyOf policy-default-oobe-prefill-user target: !KeyOf binding-password-write model: authentik_policies.policybinding -- identifiers: - order: 0 - policy: !KeyOf policy-default-oobe-flow-set-authentication - target: !KeyOf binding-login - model: authentik_policies.policybinding diff --git a/lifecycle/ak b/lifecycle/ak index 56c3094898..ddf09f196b 100755 --- a/lifecycle/ak +++ b/lifecycle/ak @@ -84,12 +84,6 @@ if [[ "$1" == "server" ]]; then elif [[ "$1" == "worker" ]]; then set_mode "worker" shift - # If we have bootstrap credentials set, run bootstrap tasks outside of main server - # sync, so that we can sure the first start actually has working bootstrap - # credentials - if [[ -n "${AUTHENTIK_BOOTSTRAP_PASSWORD}" || -n "${AUTHENTIK_BOOTSTRAP_TOKEN}" ]]; then - python -m manage apply_blueprint system/bootstrap.yaml || true - fi check_if_root "python -m manage worker --pid-file ${TMPDIR}/authentik-worker.pid $@" elif [[ "$1" == "bash" ]]; then /bin/bash diff --git a/tests/live.py b/tests/live.py index db1edb6cb5..43f23487de 100644 --- a/tests/live.py +++ b/tests/live.py @@ -6,6 +6,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase from dramatiq import get_broker from structlog.stdlib import get_logger +from authentik.core.apps import Setup from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user from authentik.tasks.test import use_test_broker @@ -27,6 +28,7 @@ class E2ETestMixin(DockerTestCase): self.wait_timeout = 60 self.logger = get_logger() self.user = create_test_admin_user() + Setup.set(True) super().setUp() @classmethod diff --git a/website/docs/developer-docs/setup/frontend-dev-environment.md b/website/docs/developer-docs/setup/frontend-dev-environment.md index c8a8fc21c0..b5209f1421 100644 --- a/website/docs/developer-docs/setup/frontend-dev-environment.md +++ b/website/docs/developer-docs/setup/frontend-dev-environment.md @@ -62,5 +62,3 @@ If you're focusing solely on frontend development, you can create a minimal deve ``` You can now access authentik on http://localhost:9000 (or https://localhost:9443). - -You might also want to complete the initial setup under `/if/flow/initial-setup/`. diff --git a/website/docs/developer-docs/setup/full-dev-environment.mdx b/website/docs/developer-docs/setup/full-dev-environment.mdx index f05b22188c..c085e5dd5d 100644 --- a/website/docs/developer-docs/setup/full-dev-environment.mdx +++ b/website/docs/developer-docs/setup/full-dev-environment.mdx @@ -174,7 +174,7 @@ make web-watch After the frontend build completes, set a password for the default admin user (**akadmin**): -1. Navigate to http://localhost:9000/if/flow/initial-setup/ in your browser. +1. Navigate to http://localhost:9000 in your browser. 2. Follow the prompts to set up your admin account. From now on, you can access authentik at http://localhost:9000 using the credentials you defined in Step 2. diff --git a/website/docs/install-config/install/aws.md b/website/docs/install-config/install/aws.md index a68311cef9..e0aa435f3b 100644 --- a/website/docs/install-config/install/aws.md +++ b/website/docs/install-config/install/aws.md @@ -30,12 +30,12 @@ The stack will output the endpoint of the ALB to which you can point your DNS re ## Access authentik from AWS CloudFormation -To launch authentik, in your browser go to: +To start the initial setup, navigate to `http://`. -`http:///if/flow/initial-setup/` +You are then prompted to set a password for the `akadmin` user (the default user). -:::info Initial setup in browser -You will get a `Not Found` error if initial setup URL doesn't include the trailing forward slash `/`. Also verify that the authentik server, worker, and PostgreSQL database are running and healthy. Review additional tips in our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps). +:::info Issues with initial setup +If you run into issues, refer to our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps). ::: ### Further customization diff --git a/website/docs/install-config/install/docker-compose.mdx b/website/docs/install-config/install/docker-compose.mdx index 14211764bc..e730f53202 100644 --- a/website/docs/install-config/install/docker-compose.mdx +++ b/website/docs/install-config/install/docker-compose.mdx @@ -116,14 +116,14 @@ The `compose.yml` file statically references the latest version available at the ## Access authentik -To start the initial setup, navigate to `http://:9000/if/flow/initial-setup/`. - -:::info Initial setup in browser -You will get a `Not Found` error if initial setup URL doesn't include the trailing forward slash `/`. Also verify that the authentik server, worker, and PostgreSQL database are running and healthy. Review additional tips in our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps). -::: +To start the initial setup, navigate to `http://:9000`. You are then prompted to set a password for the `akadmin` user (the default user). +:::info Issues with initial setup +If you run into issues, refer to our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps). +::: + ## First steps in authentik import BlurbFirstSteps from "../first-steps/_blurb_first_steps.mdx"; diff --git a/website/docs/install-config/install/kubernetes.md b/website/docs/install-config/install/kubernetes.md index d95ba469a8..d343633f4b 100644 --- a/website/docs/install-config/install/kubernetes.md +++ b/website/docs/install-config/install/kubernetes.md @@ -109,10 +109,12 @@ During the installation process, the database migrations will be applied automat ## Access authentik -After the installation is complete, access authentik at `https:///if/flow/initial-setup/`. Here, you can set a password for the default `akadmin` user. +To start the initial setup, navigate to `http://:9000`. -:::info Initial setup in browser -You will get a `Not Found` error if initial setup URL doesn't include the trailing forward slash `/`. Also verify that the authentik server, worker, and PostgreSQL database are running and healthy. Review additional tips in our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps). +You are then prompted to set a password for the `akadmin` user (the default user). + +:::info Issues with initial setup +If you run into issues, refer to our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps). ::: ## First steps in authentik