mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
Compare commits
8 Commits
0459568a96
...
ea4848c7c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4848c7c6 | ||
|
|
2fd9a09055 | ||
|
|
b07b71f528 | ||
|
|
c058363180 | ||
|
|
b5a92b783f | ||
|
|
a4c60ece8b | ||
|
|
d1d38edb50 | ||
|
|
c6ee7b6881 |
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
61
authentik/core/migrations/0058_setup.py
Normal file
61
authentik/core/migrations/0058_setup.py
Normal file
@@ -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)]
|
||||
0
authentik/core/setup/__init__.py
Normal file
0
authentik/core/setup/__init__.py
Normal file
38
authentik/core/setup/signals.py
Normal file
38
authentik/core/setup/signals.py
Normal file
@@ -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)
|
||||
80
authentik/core/setup/views.py
Normal file
80
authentik/core/setup/views.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
156
authentik/core/tests/test_setup.py
Normal file
156
authentik/core/tests/test_setup.py
Normal file
@@ -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"])
|
||||
@@ -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/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
|
||||
path(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector
|
||||
from authentik.endpoints.controller import BaseController
|
||||
from authentik.endpoints.models import StageMode
|
||||
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
@@ -25,16 +27,22 @@ class TestAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
def test_endpoint_stage_fleet(self):
|
||||
connector = FleetConnector.objects.create(name=generate_id())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:stages-endpoint-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"connector": str(connector.pk),
|
||||
"mode": StageMode.REQUIRED,
|
||||
},
|
||||
)
|
||||
def test_endpoint_stage_agent_no_stage(self):
|
||||
connector = AgentConnector.objects.create(name=generate_id())
|
||||
|
||||
class controller(BaseController):
|
||||
def capabilities(self):
|
||||
return []
|
||||
|
||||
with patch.object(AgentConnector, "controller", PropertyMock(return_value=controller)):
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:stages-endpoint-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"connector": str(connector.pk),
|
||||
"mode": StageMode.REQUIRED,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {"connector": ["Selected connector is not compatible with this stage."]}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import re
|
||||
from plistlib import loads
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.x509 import load_der_x509_certificate
|
||||
from django.db import transaction
|
||||
from requests import RequestException
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
|
||||
from authentik.endpoints.facts import (
|
||||
DeviceFacts,
|
||||
@@ -44,7 +48,7 @@ class FleetController(BaseController[DBC]):
|
||||
return "fleetdm.com"
|
||||
|
||||
def capabilities(self) -> list[Capabilities]:
|
||||
return [Capabilities.ENROLL_AUTOMATIC_API]
|
||||
return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.connector.url}{path}"
|
||||
@@ -76,8 +80,44 @@ class FleetController(BaseController[DBC]):
|
||||
except RequestException as exc:
|
||||
raise ConnectorSyncException(exc) from exc
|
||||
|
||||
@property
|
||||
def mtls_ca_managed(self) -> str:
|
||||
return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
|
||||
|
||||
def _sync_mtls_ca(self):
|
||||
"""Sync conditional access Root CA for mTLS"""
|
||||
try:
|
||||
# Fleet doesn't have an API to just get the Conditional Access Root CA Cert (yet),
|
||||
# hence we fetch the apple config profile and extract it
|
||||
res = self._session.get(self._url("/api/v1/fleet/conditional_access/idp/apple/profile"))
|
||||
res.raise_for_status()
|
||||
profile = loads(res.text).get("PayloadContent", [])
|
||||
raw_cert = None
|
||||
for payload in profile:
|
||||
if payload.get("PayloadIdentifier", "") != "com.fleetdm.conditional-access-ca":
|
||||
continue
|
||||
raw_cert = payload.get("PayloadContent")
|
||||
if not raw_cert:
|
||||
raise ConnectorSyncException("Failed to get conditional acccess CA")
|
||||
except RequestException as exc:
|
||||
raise ConnectorSyncException(exc) from exc
|
||||
cert = load_der_x509_certificate(raw_cert)
|
||||
CertificateKeyPair.objects.update_or_create(
|
||||
managed=self.mtls_ca_managed,
|
||||
defaults={
|
||||
"name": f"Fleet Endpoint connector {self.connector.name}",
|
||||
"certificate_data": cert.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
).decode("utf-8"),
|
||||
},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def sync_endpoints(self) -> None:
|
||||
try:
|
||||
self._sync_mtls_ca()
|
||||
except ConnectorSyncException as exc:
|
||||
self.logger.warning("Failed to sync conditional access CA", exc=exc)
|
||||
for host in self._paginate_hosts():
|
||||
serial = host["hardware_serial"]
|
||||
device, _ = Device.objects.get_or_create(
|
||||
@@ -198,6 +238,8 @@ class FleetController(BaseController[DBC]):
|
||||
for policy in host.get("policies", [])
|
||||
],
|
||||
"agent_version": fleet_version,
|
||||
# Host UUID is required for conditional access matching
|
||||
"uuid": host.get("uuid", "").lower(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ class FleetConnector(Connector):
|
||||
def component(self) -> str:
|
||||
return "ak-endpoints-connector-fleet-form"
|
||||
|
||||
@property
|
||||
def stage(self):
|
||||
from authentik.enterprise.endpoints.connectors.fleet.stage import FleetStageView
|
||||
|
||||
return FleetStageView
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Fleet Connector")
|
||||
verbose_name_plural = _("Fleet Connectors")
|
||||
|
||||
51
authentik/enterprise/endpoints/connectors/fleet/stage.py
Normal file
51
authentik/enterprise/endpoints/connectors/fleet/stage.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from cryptography.x509 import (
|
||||
Certificate,
|
||||
Extension,
|
||||
SubjectAlternativeName,
|
||||
UniformResourceIdentifier,
|
||||
)
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256
|
||||
from authentik.endpoints.models import Device, EndpointStage, StageMode
|
||||
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
|
||||
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE, MTLSStageView
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
|
||||
FLEET_CONDITIONAL_ACCESS_URI_PREFIX = "urn:device:apple:uuid:"
|
||||
|
||||
|
||||
class FleetStageView(MTLSStageView):
|
||||
def get_authorities(self):
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
connector = FleetConnector.objects.filter(pk=stage.connector_id).first()
|
||||
controller = connector.controller(connector)
|
||||
kp = CertificateKeyPair.objects.filter(managed=controller.mtls_ca_managed).first()
|
||||
return [kp] if kp else None
|
||||
|
||||
def lookup_device(self, cert: Certificate, mode: StageMode):
|
||||
san_ext: Extension[SubjectAlternativeName] = cert.extensions.get_extension_for_oid(
|
||||
SubjectAlternativeName.oid
|
||||
)
|
||||
raw_values = san_ext.value.get_values_for_type(UniformResourceIdentifier)
|
||||
values = [x.removeprefix(FLEET_CONDITIONAL_ACCESS_URI_PREFIX).lower() for x in raw_values]
|
||||
self.logger.debug("Looking for devices with uuid", fleet_device_uuid=values)
|
||||
device = Device.objects.filter(
|
||||
**{"deviceconnection__devicefactsnapshot__data__vendor__fleetdm.com__uuid__in": values}
|
||||
).first()
|
||||
if not device and mode == StageMode.REQUIRED:
|
||||
raise PermissionDenied("Failed to find device")
|
||||
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
|
||||
self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert)
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
try:
|
||||
cert = self.get_cert(stage.mode)
|
||||
if not cert:
|
||||
return self.executor.stage_ok()
|
||||
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
|
||||
return self.lookup_device(cert, stage.mode)
|
||||
except PermissionDenied as exc:
|
||||
return self.executor.stage_invalid(error_message=exc.detail)
|
||||
23
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_host.pem
vendored
Normal file
23
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_host.pem
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDwDCCAqigAwIBAgIBBDANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAi
|
||||
BgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NF
|
||||
UCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI2
|
||||
MDMxODExMTc1NFoXDTI3MDQyMDExMjc1NFowLDEqMCgGA1UEAxMhRmxlZXQgY29u
|
||||
ZGl0aW9uYWwgYWNjZXNzIGZvciBPa3RhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
||||
MIIBCgKCAQEA3xuKxQQ8JSA4qCJ6RfOB7tbQurhwXiaJSLUDG7R5ncdRcd9LH/9y
|
||||
5ZyI5kQACOwfICHmv02zR4/CrurfzXabo3CCpvcMdS7JI/FzP1GIIZ5RsR7oPFC6
|
||||
JJg3m5BHuoHsUtCD7w0D52WiE7XVfbw47h2ChKmGMhkSrBvQnp3dHFEt8ntbl1/q
|
||||
zCSuQaLeR2sQFurBDVBdinEgsvb1YHaYHi4tdFx5joG64Q/nJXyA2OM4hO9uBF+G
|
||||
c4UVTzubx5sxwONcPhC9H+eLMpF1VHeU9gAGBlruVusUEYDmlqYQuA+bW5fTr4Zd
|
||||
ZmJ5e+CzzUBYHduAML9a5S+1jbxSPZFBSwIDAQABo4GvMIGsMA4GA1UdDwEB/wQE
|
||||
AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUPrc1+LvbR9WoJIWZ
|
||||
7YQa/3IX2w8wHwYDVR0jBBgwFoAUfl92kU2qcH4e+hypez4kEnqMbk4wRQYDVR0R
|
||||
BD4wPIY6dXJuOmRldmljZTphcHBsZTp1dWlkOjVCRjQyMkQ2LTZFQUItNTE1Ni1B
|
||||
QzVBLTlFQURDOTUyNDcxMzANBgkqhkiG9w0BAQsFAAOCAQEAGfxJ/u4271tnUUTB
|
||||
J39YU6z2Ciav+9G3BtbvxBXI57Po7zCE6Z1sVkvYq6Xd0CcItPWRjbSPEy78ZzS0
|
||||
By+gPy5fkKc8HHJ5I1wK890xbLBUS1P4EbdVBzI9ggouEa3B2asE10asnzLoKE4C
|
||||
0FYWQwrzCsso8yxsJj1S8RKtd6MMbCis/9OQSC8om2tu6cLO+OftVn5DHtNWFidw
|
||||
tAl/oHn2cZPUfZGpJGrHNZlp5w1c1dYfQeiPayoQIbsF+8eMV424G76z/8UPhMBs
|
||||
R23LByv4TlUOPAGn2TRa2WtLIXs7FgqXRIFW4CjsPsEpXSVNlkYcn/VHY7Jl13zz
|
||||
CRQ1Pg==
|
||||
-----END CERTIFICATE-----
|
||||
46
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_profile.mobileconfig
vendored
Normal file
46
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_profile.mobileconfig
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<!-- Trusted CA certificate -->
|
||||
<dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>conditional_access_ca.der</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>MIIDjzCCAnegAwIBAgIBATANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAiBgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NFUCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI1MTIwOTEyMjI1MVoXDTM1MTIwOTEyMjI1MVowaTEJMAcGA1UEBhMAMSQwIgYDVQQKExtMb2NhbCBjZXJ0aWZpY2F0ZSBhdXRob3JpdHkxEDAOBgNVBAsTB1NDRVAgQ0ExJDAiBgNVBAMTG0ZsZWV0IGNvbmRpdGlvbmFsIGFjY2VzcyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANrgCcpzQci2UhH+Dn0eHopnnbx3HbMabMCHXm6xteMVFLrdQJDTFrZCQzcexUgbpPJ0az6mn4szo+E3stn0y2PPWsiAiVhFwp5M9HwNg18rPgDmITv2pM3l/hlEsfggjq6TEVO2gRcq4NujEGagcYX6kp6nWxh6bbRngQ/hlK6mXItWV3x0G9eTcbFObwZhbuC2dNbccytdqbVEIpBjp6fftQnQwAaUVjoyZBFlf1C1cDV4+1jpaVsIj11U1olA33GJCHcZQ4CJEsgh8yiSsvkH5RNf94CGINB5ixsMfppjSXV/vNkWDKEfmUXW2q4ft7KK/L/SRq8QSB4VqTAp2GsCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH5fdpFNqnB+HvocqXs+JBJ6jG5OMA0GCSqGSIb3DQEBCwUAA4IBAQAJr4bTGlrANoHStu4Y+OXjGbEQjZOe546Bcln4eWrEB16eaVzfKuZgjJYdcOmp36/v34QY/OCXEIsixrBU5aW/Sr53IK6UQSZV3O3xbBc4Aert7AbeJ4NVGZyelfVQo/5G0qM6k9p0+zpIZqNAzFbhcSPIzuE7ig2OGsFoQU+bXhzk09bsZ+u4BXibzVNfMuMG+DHNv0PRjll272nEPI3bGwHF5tdrnfJG6e9t+qK9j9UqmSlBknHQJNeU5o8IDcmWYjWtOuBzecYsg8pZzXabJqlHTBIz/h7waRe7jtrK+XopK3jghRf9JTL+i0Y8NbVjoNkIoS3xMeRhnNbR9lw1</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Fleet conditional access CA certificate</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Fleet conditional access CA</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.fleetdm.conditional-access-ca</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.root</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>ef1b2231-ad80-5511-9893-1f9838295147</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures SCEP enrollment for Okta conditional access</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Fleet conditional access for Okta</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.fleetdm.conditional-access-okta</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Fleet Device Management</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadScope</key>
|
||||
<string>User</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>6fa509a3-feca-56f7-a283-d6a81c733ed2</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"created_at": "2025-06-25T22:21:35Z",
|
||||
"updated_at": "2025-12-20T11:42:09Z",
|
||||
"created_at": "2026-02-18T16:31:34Z",
|
||||
"updated_at": "2026-03-18T11:29:18Z",
|
||||
"software": null,
|
||||
"software_updated_at": "2025-10-22T02:24:25Z",
|
||||
"id": 1,
|
||||
"detail_updated_at": "2025-10-23T23:30:31Z",
|
||||
"label_updated_at": "2025-10-23T23:30:31Z",
|
||||
"policy_updated_at": "2025-10-23T23:02:11Z",
|
||||
"last_enrolled_at": "2025-06-25T22:21:37Z",
|
||||
"seen_time": "2025-10-23T23:59:08Z",
|
||||
"software_updated_at": "2026-03-18T11:29:17Z",
|
||||
"id": 19,
|
||||
"detail_updated_at": "2026-03-18T11:29:18Z",
|
||||
"label_updated_at": "2026-03-18T11:29:18Z",
|
||||
"policy_updated_at": "2026-03-18T11:29:18Z",
|
||||
"last_enrolled_at": "2026-02-18T16:31:45Z",
|
||||
"seen_time": "2026-03-18T11:31:34Z",
|
||||
"refetch_requested": false,
|
||||
"hostname": "jens-mac-vm.local",
|
||||
"uuid": "C8B98348-A0A6-5838-A321-57B59D788269",
|
||||
"uuid": "5BF422D6-6EAB-5156-AC5A-9EADC9524713",
|
||||
"platform": "darwin",
|
||||
"osquery_version": "5.19.0",
|
||||
"osquery_version": "5.21.0",
|
||||
"orbit_version": null,
|
||||
"fleet_desktop_version": null,
|
||||
"scripts_enabled": null,
|
||||
"os_version": "macOS 26.0.1",
|
||||
"build": "25A362",
|
||||
"os_version": "macOS 26.3",
|
||||
"build": "25D125",
|
||||
"platform_like": "darwin",
|
||||
"code_name": "",
|
||||
"uptime": 256356000000000,
|
||||
"uptime": 653014000000000,
|
||||
"memory": 4294967296,
|
||||
"cpu_type": "arm64e",
|
||||
"cpu_subtype": "ARM64E",
|
||||
@@ -31,38 +31,41 @@
|
||||
"hardware_vendor": "Apple Inc.",
|
||||
"hardware_model": "VirtualMac2,1",
|
||||
"hardware_version": "",
|
||||
"hardware_serial": "Z5DDF07GK6",
|
||||
"hardware_serial": "ZV35VFDD50",
|
||||
"computer_name": "jens-mac-vm",
|
||||
"timezone": null,
|
||||
"public_ip": "92.116.179.252",
|
||||
"primary_ip": "192.168.85.3",
|
||||
"primary_mac": "e6:9d:21:c2:2f:19",
|
||||
"primary_ip": "192.168.64.7",
|
||||
"primary_mac": "5e:72:1c:89:98:29",
|
||||
"distributed_interval": 10,
|
||||
"config_tls_refresh": 60,
|
||||
"logger_tls_period": 10,
|
||||
"team_id": 2,
|
||||
"team_id": 5,
|
||||
"pack_stats": null,
|
||||
"team_name": "prod",
|
||||
"gigs_disk_space_available": 23.82,
|
||||
"percent_disk_space_available": 37,
|
||||
"team_name": "dev",
|
||||
"gigs_disk_space_available": 16.52,
|
||||
"percent_disk_space_available": 26,
|
||||
"gigs_total_disk_space": 62.83,
|
||||
"gigs_all_disk_space": null,
|
||||
"issues": {
|
||||
"failing_policies_count": 1,
|
||||
"critical_vulnerabilities_count": 2,
|
||||
"total_issues_count": 3
|
||||
"critical_vulnerabilities_count": 0,
|
||||
"total_issues_count": 1
|
||||
},
|
||||
"device_mapping": null,
|
||||
"mdm": {
|
||||
"enrollment_status": "On (manual)",
|
||||
"dep_profile_error": false,
|
||||
"server_url": "https://fleet.beryjuio-home.k8s.beryju.io/mdm/apple/mdm",
|
||||
"server_url": "https://fleet.beryjuio-prod.k8s.beryju.io/mdm/apple/mdm",
|
||||
"name": "Fleet",
|
||||
"encryption_key_available": false,
|
||||
"connected_to_fleet": true
|
||||
},
|
||||
"refetch_critical_queries_until": null,
|
||||
"last_restarted_at": "2025-10-21T00:17:55Z",
|
||||
"status": "offline",
|
||||
"last_restarted_at": "2026-03-10T22:05:44.00887Z",
|
||||
"status": "online",
|
||||
"display_text": "jens-mac-vm.local",
|
||||
"display_name": "jens-mac-vm"
|
||||
"display_name": "jens-mac-vm",
|
||||
"fleet_id": 5,
|
||||
"fleet_name": "dev"
|
||||
}
|
||||
|
||||
@@ -21,12 +21,19 @@ TEST_HOST = {"hosts": [TEST_HOST_UBUNTU, TEST_HOST_MACOS, TEST_HOST_WINDOWS, TES
|
||||
class TestFleetConnector(APITestCase):
|
||||
def setUp(self):
|
||||
self.connector = FleetConnector.objects.create(
|
||||
name=generate_id(), url="http://localhost", token=generate_id()
|
||||
name=generate_id(),
|
||||
url="http://localhost",
|
||||
token=generate_id(),
|
||||
map_teams_access_group=True,
|
||||
)
|
||||
|
||||
def test_sync(self):
|
||||
controller = self.connector.controller(self.connector)
|
||||
with Mocker() as mock:
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
|
||||
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json=TEST_HOST,
|
||||
@@ -40,6 +47,9 @@ class TestFleetConnector(APITestCase):
|
||||
identifier="VMware-56 4d 4a 5a b0 22 7b d7-9b a5 0b dc 8f f2 3b 60"
|
||||
).first()
|
||||
self.assertIsNotNone(device)
|
||||
group = device.access_group
|
||||
self.assertIsNotNone(group)
|
||||
self.assertEqual(group.name, "prod")
|
||||
self.assertEqual(
|
||||
device.cached_facts.data,
|
||||
{
|
||||
@@ -50,7 +60,13 @@ class TestFleetConnector(APITestCase):
|
||||
"version": "24.04.3 LTS",
|
||||
},
|
||||
"disks": [],
|
||||
"vendor": {"fleetdm.com": {"policies": [], "agent_version": ""}},
|
||||
"vendor": {
|
||||
"fleetdm.com": {
|
||||
"policies": [],
|
||||
"agent_version": "",
|
||||
"uuid": "5a4a4d56-22b0-d77b-9ba5-0bdc8ff23b60",
|
||||
}
|
||||
},
|
||||
"network": {"hostname": "ubuntu-desktop", "interfaces": []},
|
||||
"hardware": {
|
||||
"model": "VMware20,1",
|
||||
@@ -72,6 +88,10 @@ class TestFleetConnector(APITestCase):
|
||||
self.connector.save()
|
||||
controller = self.connector.controller(self.connector)
|
||||
with Mocker() as mock:
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
|
||||
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json=TEST_HOST,
|
||||
@@ -81,11 +101,13 @@ class TestFleetConnector(APITestCase):
|
||||
json={"hosts": []},
|
||||
)
|
||||
controller.sync_endpoints()
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.call_count, 3)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[0].headers["foo"], "bar")
|
||||
self.assertEqual(mock.request_history[1].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].headers["foo"], "bar")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
self.assertEqual(mock.request_history[2].headers["foo"], "bar")
|
||||
|
||||
def test_map_host_linux(self):
|
||||
controller = self.connector.controller(self.connector)
|
||||
@@ -128,6 +150,6 @@ class TestFleetConnector(APITestCase):
|
||||
"arch": "arm64e",
|
||||
"family": OSFamily.macOS,
|
||||
"name": "macOS",
|
||||
"version": "26.0.1",
|
||||
"version": "26.3",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from json import loads
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
|
||||
from django.urls import reverse
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.core.tests.utils import (
|
||||
create_test_flow,
|
||||
)
|
||||
from authentik.endpoints.models import Device, EndpointStage, StageMode
|
||||
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
|
||||
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
|
||||
|
||||
class FleetConnectorStageTests(FlowTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.connector = FleetConnector.objects.create(
|
||||
name=generate_id(), url="http://localhost", token=generate_id()
|
||||
)
|
||||
|
||||
controller = self.connector.controller(self.connector)
|
||||
with Mocker() as mock:
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
|
||||
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json={"hosts": [loads(load_fixture("fixtures/host_macos.json"))]},
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=1&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json={"hosts": []},
|
||||
)
|
||||
controller.sync_endpoints()
|
||||
|
||||
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||
self.stage = EndpointStage.objects.create(
|
||||
name=generate_id(),
|
||||
mode=StageMode.REQUIRED,
|
||||
connector=self.connector,
|
||||
)
|
||||
|
||||
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
|
||||
|
||||
self.host_cert = load_fixture("fixtures/cond_acc_host.pem")
|
||||
|
||||
def _format_traefik(self, cert: str | None = None):
|
||||
cert = cert if cert else self.host_cert
|
||||
return cert.replace(PEM_HEADER, "").replace(PEM_FOOTER, "").replace("\n", "")
|
||||
|
||||
def test_assoc(self):
|
||||
dev = Device.objects.get(identifier="ZV35VFDD50")
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
plan = plan()
|
||||
self.assertEqual(plan.context[PLAN_CONTEXT_DEVICE], dev)
|
||||
self.assertEqual(
|
||||
plan.context[PLAN_CONTEXT_CERTIFICATE]["subject"],
|
||||
"CN=Fleet conditional access for Okta",
|
||||
)
|
||||
|
||||
def test_assoc_not_found(self):
|
||||
dev = Device.objects.get(identifier="ZV35VFDD50")
|
||||
dev.delete()
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
plan = plan()
|
||||
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)
|
||||
@@ -15,6 +15,7 @@ from cryptography.x509 import (
|
||||
)
|
||||
from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import User
|
||||
@@ -25,7 +26,6 @@ from authentik.enterprise.stages.mtls.models import (
|
||||
MutualTLSStage,
|
||||
UserAttributes,
|
||||
)
|
||||
from authentik.flows.challenge import AccessDeniedChallenge
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
@@ -217,8 +217,7 @@ class MTLSStageView(ChallengeStageView):
|
||||
return None
|
||||
return str(_cert_attr[0])
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: MutualTLSStage = self.executor.current_stage
|
||||
def get_cert(self, mode: StageMode):
|
||||
certs = [
|
||||
*self._parse_cert_xfcc(),
|
||||
*self._parse_cert_nginx(),
|
||||
@@ -228,21 +227,26 @@ class MTLSStageView(ChallengeStageView):
|
||||
authorities = self.get_authorities()
|
||||
if not authorities:
|
||||
self.logger.warning("No Certificate authority found")
|
||||
if stage.mode == StageMode.OPTIONAL:
|
||||
return self.executor.stage_ok()
|
||||
if stage.mode == StageMode.REQUIRED:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
if mode == StageMode.OPTIONAL:
|
||||
return None
|
||||
if mode == StageMode.REQUIRED:
|
||||
raise PermissionDenied("Unknown error")
|
||||
cert = self.validate_cert(authorities, certs)
|
||||
if not cert and stage.mode == StageMode.REQUIRED:
|
||||
if not cert and mode == StageMode.REQUIRED:
|
||||
self.logger.warning("Client certificate required but no certificates given")
|
||||
return super().dispatch(
|
||||
request,
|
||||
*args,
|
||||
error_message=_("Certificate required but no certificate was given."),
|
||||
**kwargs,
|
||||
)
|
||||
if not cert and stage.mode == StageMode.OPTIONAL:
|
||||
raise PermissionDenied(str(_("Certificate required but no certificate was given.")))
|
||||
if not cert and mode == StageMode.OPTIONAL:
|
||||
self.logger.info("No certificate given, continuing")
|
||||
return None
|
||||
return cert
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: MutualTLSStage = self.executor.current_stage
|
||||
try:
|
||||
cert = self.get_cert(stage.mode)
|
||||
except PermissionDenied as exc:
|
||||
return self.executor.stage_invalid(error_message=exc.detail)
|
||||
if not cert:
|
||||
return self.executor.stage_ok()
|
||||
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
|
||||
existing_user = self.check_if_user(cert)
|
||||
@@ -251,15 +255,5 @@ class MTLSStageView(ChallengeStageView):
|
||||
elif existing_user:
|
||||
self.auth_user(existing_user, cert)
|
||||
else:
|
||||
return super().dispatch(
|
||||
request, *args, error_message=_("No user found for certificate."), **kwargs
|
||||
)
|
||||
return self.executor.stage_invalid(_("No user found for certificate."))
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def get_challenge(self, *args, error_message: str | None = None, **kwargs):
|
||||
return AccessDeniedChallenge(
|
||||
data={
|
||||
"component": "ak-stage-access-denied",
|
||||
"error_message": str(error_message or "Unknown error"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,6 +48,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
},
|
||||
)
|
||||
@@ -66,6 +67,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
@@ -74,6 +76,26 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
body = loads(res.content.decode())
|
||||
self.assertEqual(body["error"], "authorization_pending")
|
||||
|
||||
def test_code_no_auth(self):
|
||||
"""Test code with user"""
|
||||
device_token = DeviceToken.objects.create(
|
||||
provider=self.provider,
|
||||
user_code=generate_code_fixed_length(),
|
||||
device_code=generate_id(),
|
||||
user=self.user,
|
||||
)
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
body = loads(res.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_client")
|
||||
|
||||
def test_code(self):
|
||||
"""Test code with user"""
|
||||
device_token = DeviceToken.objects.create(
|
||||
@@ -86,6 +108,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
@@ -105,6 +128,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",
|
||||
|
||||
@@ -165,7 +165,15 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||
# Confidential clients MUST authenticate to the token endpoint per
|
||||
# RFC 6749 §2.3.1. The device code grant (RFC 8628 §3.4) inherits
|
||||
# that requirement - the device_code alone is not a substitute for
|
||||
# client credentials.
|
||||
if self.grant_type in [
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
GRANT_TYPE_DEVICE_CODE,
|
||||
]:
|
||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
|
||||
self.provider.client_secret, self.client_secret
|
||||
):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
19
authentik/tenants/management/commands/set_flag.py
Normal file
19
authentik/tenants/management/commands/set_flag.py
Normal file
@@ -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}.")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,8 +42,9 @@ WORKDIR /go/src/goauthentik.io
|
||||
|
||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||
dpkg --add-architecture arm64 && \
|
||||
dpkg --add-architecture amd64 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu
|
||||
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu crossbuild-essential-amd64 gcc-x86-64-linux-gnu
|
||||
|
||||
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
||||
@@ -62,7 +63,8 @@ COPY ./packages/client-go /go/src/goauthentik.io/packages/client-go
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||
if [ "$TARGETARCH" = "arm64" ] && [ "$(uname -m)" != "aarch64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||
if [ "$TARGETARCH" = "amd64" ] && [ "$(uname -m)" != "x86_64" ]; then export CC=x86_64-linux-gnu-gcc; fi && \
|
||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||
go build -o /go/authentik ./cmd/server
|
||||
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1843,9 +1843,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -5373,9 +5373,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -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
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -14810,9 +14810,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -37,4 +37,4 @@ Follow these instructions to configure the Fleet connector in authentik:
|
||||
The **Map teams to device access group** setting will not detect changes to a device's groups membership in Fleet. If the device's groups change, you will need to manually configure a [device access group](../../authentik-agent/device-authentication/device-access-groups.mdx).
|
||||
:::
|
||||
|
||||
After creating the connector, it can be used in the [Endpoint Stage](../../../add-secure-apps/flows-stages/stages/endpoint/index.md). Refer to [Device compliance policy](../device-compliance-policy.md) for more information on using device facts from the connector in a flow.
|
||||
After creating the connector, it can be used in the [Endpoint Stage](../../../add-secure-apps/flows-stages/stages/endpoint/index.md). Refer to [Fleet conditional access](../fleet-conditional-access.md) and [Device compliance policy](../device-compliance-policy.md) for more information on using device facts from the connector in a flow.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Fleet conditional access for Apple devices
|
||||
sidebar_label: Fleet conditional access
|
||||
tags: [device compliance, compliance, conditional access, fleet, fleetdm]
|
||||
authentik_version: "2026.5"
|
||||
---
|
||||
|
||||
authentik can be configured to restrict access to specific services so that only Fleet-registered Apple devices are allowed.
|
||||
|
||||
authentik automatically retrieves the Conditional Access Root CA certificate from Fleet via the Fleet connector. The Endpoint stage then verifies the device’s Fleet-issued certificate against this Root CA. If validation succeeds, the device is bound to the user’s current authentik session.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must have [configured compliance](./configuration.md) in authentik
|
||||
- The [Fleet connector](./connectors/fleetdm.md) must be configured in authentik
|
||||
- Conditional access Root CA Certificate must be pulled from Fleet via the Fleet connector. This is an automatic process
|
||||
- A Fleet Enterprise license is required
|
||||
|
||||
## Configure Fleet and devices
|
||||
|
||||
A Fleet Conditional Access configuration profile must be applied to every device that you wish to apply conditional access to. Please note that that this will only function on iOS and iPadOS devices that are enrolled via Apple Automated Device Enrollment (ADE). The same limitation does not apply to macOS devices.
|
||||
|
||||
1. Log in to your Fleet dashboard as an administrator.
|
||||
2. Navigate to **Settings** > **Integrations** > **Conditional Access**.
|
||||
3. Next to **Okta**, click **Connect**. Despite being named **Okta**, the same profile is used for all integrations.
|
||||
4. Click the copy button to the right of **User scope profile**.
|
||||
5. Save the text as a `.mobileconfig` file. Apply it as a Fleet configuration profile on any Apple device that you wish to apply conditional access to.
|
||||
Refer to the [Fleet Custom OS settings documentation](https://fleetdm.com/guides/custom-os-settings) for more information on applying configuration profiles.
|
||||
|
||||
## Configure authentik
|
||||
|
||||
This configuration applies to a specific flow, such as an authentication flow.
|
||||
|
||||
### Bind Endpoint stage to flow
|
||||
|
||||
The flow must have an [Endpoint stage](../../add-secure-apps/flows-stages/stages/endpoint/index.md) bound to it.
|
||||
|
||||
1. Log in to authentik as an administrator and open the authentik Admin interface.
|
||||
2. Navigate to **Flows and Stages > Flows**.
|
||||
3. Select the flow that you want to modify.
|
||||
4. Open the **Stage Bindings** tab and click **Create and bind stage**.
|
||||
5. Select **Endpoint Stage** as the stage type, click **Next**, and configure the following settings:
|
||||
- **Name**: provide a name for the stage
|
||||
- **Connector**: select the Fleet connector
|
||||
- **Mode**: set to `Device required`
|
||||
6. Click **Next**.
|
||||
7. Select the order for the stage. Ensure that this places the Endpoint stage in the flow wherever you want device access to be checked.
|
||||
8. Click **Finish**.
|
||||
@@ -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://<domain_you_configured>`.
|
||||
|
||||
`http://<domain_you_configured>/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
|
||||
|
||||
@@ -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://<your server's IP or hostname>: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://<your server's IP or hostname>: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";
|
||||
|
||||
@@ -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://<authentik-host-name>/if/flow/initial-setup/`. Here, you can set a password for the default `akadmin` user.
|
||||
To start the initial setup, navigate to `http://<your server's IP or hostname>: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
|
||||
|
||||
@@ -876,6 +876,7 @@ const items = [
|
||||
"endpoint-devices/device-compliance/device-reporting",
|
||||
"endpoint-devices/device-compliance/device-compliance-policy",
|
||||
"endpoint-devices/device-compliance/browser-extension",
|
||||
"endpoint-devices/device-compliance/fleet-conditional-access",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Apple
|
||||
title: Log in with Apple
|
||||
sidebar_label: Apple
|
||||
tags:
|
||||
- source
|
||||
- apple
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Discord
|
||||
title: Log in with Discord
|
||||
sidebar_label: Discord
|
||||
tags:
|
||||
- source
|
||||
- discord
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Entra ID
|
||||
title: Log in with Entra ID
|
||||
sidebar_label: Entra ID
|
||||
tags:
|
||||
- source
|
||||
- entra
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Facebook
|
||||
title: Log in with Facebook
|
||||
sidebar_label: Facebook
|
||||
tags:
|
||||
- source
|
||||
- facebook
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: GitHub
|
||||
title: Log in with GitHub
|
||||
sidebar_label: GitHub
|
||||
tags:
|
||||
- source
|
||||
- github
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Keycloak
|
||||
title: Log in with Keycloak
|
||||
sidebar_label: Keycloak
|
||||
tags:
|
||||
- source
|
||||
- keycloak
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Mailcow
|
||||
title: Log in with Mailcow
|
||||
sidebar_label: Mailcow
|
||||
tags:
|
||||
- source
|
||||
- mailcow
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Okta
|
||||
title: Log in with Okta
|
||||
sidebar_label: Okta
|
||||
description: "Integrate Okta as a source in authentik"
|
||||
tags: [source, okta]
|
||||
---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Plex
|
||||
title: Log in with Plex
|
||||
sidebar_label: Plex
|
||||
tags:
|
||||
- source
|
||||
- plex
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Shibboleth
|
||||
title: Log in with Shibboleth
|
||||
sidebar_label: Shibboleth
|
||||
tags:
|
||||
- source
|
||||
- shibboleth
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Telegram
|
||||
title: Log in with Telegram
|
||||
sidebar_label: Telegram
|
||||
support_level: community
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Twitch
|
||||
title: Log in with Twitch
|
||||
sidebar_label: Twitch
|
||||
tags:
|
||||
- source
|
||||
- twitch
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: X (Twitter)
|
||||
title: Log in with X (formerly Twitter)
|
||||
sidebar_label: X (formerly Twitter)
|
||||
tags:
|
||||
- source
|
||||
- x
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: WeChat
|
||||
title: Log in with WeChat
|
||||
sidebar_label: WeChat
|
||||
tags:
|
||||
- source
|
||||
- wechat
|
||||
|
||||
Reference in New Issue
Block a user