tenants -> brands, init new tenant model, migrate some config to tenants

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt 2023-11-06 12:46:14 +01:00
parent 2814a8e951
commit 77d8877efe
No known key found for this signature in database
GPG Key ID: 9C3FA22FABF1AA8D
112 changed files with 4114 additions and 2234 deletions

View File

@ -37,7 +37,7 @@ class SystemInfoSerializer(PassiveSerializer):
http_host = SerializerMethodField()
http_is_secure = SerializerMethodField()
runtime = SerializerMethodField()
tenant = SerializerMethodField()
brand = SerializerMethodField()
server_time = SerializerMethodField()
embedded_outpost_host = SerializerMethodField()
@ -69,9 +69,9 @@ class SystemInfoSerializer(PassiveSerializer):
"uname": " ".join(platform.uname()),
}
def get_tenant(self, request: Request) -> str:
"""Currently active tenant"""
return str(request._request.tenant)
def get_brand(self, request: Request) -> str:
"""Currently active brand"""
return str(request._request.brand)
def get_server_time(self, request: Request) -> datetime:
"""Current server time"""

View File

@ -3,7 +3,7 @@
{% load static %}
{% block title %}
API Browser - {{ tenant.branding_title }}
API Browser - {{ brand.branding_title }}
{% endblock %}
{% block head %}

View File

@ -62,7 +62,7 @@ class ConfigView(APIView):
permission_classes = [AllowAny]
def get_capabilities(self) -> list[Capabilities]:
def get_capabilities(self, request: Request) -> list[Capabilities]:
"""Get all capabilities this server instance supports"""
caps = []
deb_test = settings.DEBUG or settings.TEST
@ -70,7 +70,7 @@ class ConfigView(APIView):
caps.append(Capabilities.CAN_SAVE_MEDIA)
if GEOIP_READER.enabled:
caps.append(Capabilities.CAN_GEO_IP)
if CONFIG.get_bool("impersonation"):
if request.tenant.impersonation:
caps.append(Capabilities.CAN_IMPERSONATE)
if settings.DEBUG: # pragma: no cover
caps.append(Capabilities.CAN_DEBUG)
@ -81,7 +81,7 @@ class ConfigView(APIView):
caps.append(result)
return caps
def get_config(self) -> ConfigSerializer:
def get_config(self, request: Request) -> ConfigSerializer:
"""Get Config"""
return ConfigSerializer(
{
@ -92,7 +92,7 @@ class ConfigView(APIView):
"send_pii": CONFIG.get("error_reporting.send_pii"),
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
},
"capabilities": self.get_capabilities(),
"capabilities": self.get_capabilities(request),
"cache_timeout": CONFIG.get_int("cache.timeout"),
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
@ -103,4 +103,4 @@ class ConfigView(APIView):
@extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Retrieve public configuration options"""
return Response(self.get_config().data)
return Response(self.get_config(request).data)

View File

@ -7,16 +7,16 @@ from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.tests import apply_blueprint
from authentik.blueprints.v1.importer import Importer
from authentik.tenants.models import Tenant
from authentik.brands.models import Brand
class TestPackaged(TransactionTestCase):
"""Empty class, test methods are added dynamically"""
@apply_blueprint("default/default-tenant.yaml")
@apply_blueprint("default/default-brand.yaml")
def test_decorator_static(self):
"""Test @apply_blueprint decorator"""
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
self.assertTrue(Brand.objects.filter(domain="authentik-default").exists())
def blueprint_tester(file_name: Path) -> Callable:

View File

142
authentik/brands/api.py Normal file
View File

@ -0,0 +1,142 @@
"""Serializer for brands models"""
from typing import Any
from django.db import models
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ChoiceField, ListField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import SecretKeyFilter
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.config import CONFIG
from authentik.tenants.utils import get_current_tenant
class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API"""
href = CharField(read_only=True)
name = CharField(read_only=True)
class BrandSerializer(ModelSerializer):
"""Brand Serializer"""
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
if attrs.get("default", False):
brands = Brand.objects.filter(default=True)
if self.instance:
brands = brands.exclude(pk=self.instance.pk)
if brands.exists():
raise ValidationError({"default": "Only a single brand can be set as default."})
return super().validate(attrs)
class Meta:
model = Brand
fields = [
"brand_uuid",
"domain",
"default",
"branding_title",
"branding_logo",
"branding_favicon",
"flow_authentication",
"flow_invalidation",
"flow_recovery",
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"event_retention",
"web_certificate",
"attributes",
]
class Themes(models.TextChoices):
"""Themes"""
AUTOMATIC = "automatic"
LIGHT = "light"
DARK = "dark"
class CurrentBrandSerializer(PassiveSerializer):
"""Partial brand information for styling"""
@staticmethod
def get_default_ui_footer_links():
return get_current_tenant().footer_links
matched_domain = CharField(source="domain")
branding_title = CharField()
branding_logo = CharField()
branding_favicon = CharField()
ui_footer_links = ListField(
child=FooterLinkSerializer(),
read_only=True,
default=get_default_ui_footer_links,
)
ui_theme = ChoiceField(
choices=Themes.choices,
source="attributes.settings.theme.base",
default=Themes.AUTOMATIC,
read_only=True,
)
flow_authentication = CharField(source="flow_authentication.slug", required=False)
flow_invalidation = CharField(source="flow_invalidation.slug", required=False)
flow_recovery = CharField(source="flow_recovery.slug", required=False)
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
flow_device_code = CharField(source="flow_device_code.slug", required=False)
default_locale = CharField(read_only=True)
class BrandViewSet(UsedByMixin, ModelViewSet):
"""Brand Viewset"""
queryset = Brand.objects.all()
serializer_class = BrandSerializer
search_fields = [
"domain",
"branding_title",
"web_certificate__name",
]
filterset_fields = [
"brand_uuid",
"domain",
"default",
"branding_title",
"branding_logo",
"branding_favicon",
"flow_authentication",
"flow_invalidation",
"flow_recovery",
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"event_retention",
"web_certificate",
]
ordering = ["domain"]
filter_backends = [SecretKeyFilter, OrderingFilter, SearchFilter]
@extend_schema(
responses=CurrentBrandSerializer(many=False),
)
@action(methods=["GET"], detail=False, permission_classes=[AllowAny])
def current(self, request: Request) -> Response:
"""Get current brand"""
brand: Brand = request._request.brand
return Response(CurrentBrandSerializer(brand).data)

10
authentik/brands/apps.py Normal file
View File

@ -0,0 +1,10 @@
"""authentik brands app"""
from django.apps import AppConfig
class AuthentikBrandsConfig(AppConfig):
"""authentik Brand app"""
name = "authentik.brands"
label = "authentik_brands"
verbose_name = "authentik Brands"

View File

@ -0,0 +1,29 @@
"""Inject brand into current request"""
from typing import Callable
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.utils.translation import activate
from sentry_sdk.api import set_tag
from authentik.brands.utils import get_brand_for_request
class BrandMiddleware:
"""Add current brand to http request"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
if not hasattr(request, "brand"):
brand = get_brand_for_request(request)
setattr(request, "brand", brand)
set_tag("authentik.brand_uuid", brand.brand_uuid.hex)
set_tag("authentik.brand_domain", brand.domain)
locale = brand.default_locale
if locale != "":
activate(locale)
return self.get_response(request)

View File

@ -10,11 +10,11 @@ import authentik.lib.utils.time
class Migration(migrations.Migration):
replaces = [
("authentik_tenants", "0001_initial"),
("authentik_tenants", "0002_default"),
("authentik_tenants", "0003_tenant_branding_favicon"),
("authentik_tenants", "0004_tenant_event_retention"),
("authentik_tenants", "0005_tenant_web_certificate"),
("authentik_brands", "0001_initial"),
("authentik_brands", "0002_default"),
("authentik_brands", "0003_tenant_branding_favicon"),
("authentik_brands", "0004_tenant_event_retention"),
("authentik_brands", "0005_tenant_web_certificate"),
]
initial = True
@ -25,10 +25,10 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name="Tenant",
name="Brand",
fields=[
(
"tenant_uuid",
"brand_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
@ -37,7 +37,7 @@ class Migration(migrations.Migration):
"domain",
models.TextField(
help_text=(
"Domain that activates this tenant. Can be a superset, i.e. `a.b` for"
"Domain that activates this brand. Can be a superset, i.e. `a.b` for"
" `aa.b` and `ba.b`"
)
),
@ -53,7 +53,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_authentication",
related_name="brand_authentication",
to="authentik_flows.flow",
),
),
@ -62,7 +62,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_invalidation",
related_name="brand_invalidation",
to="authentik_flows.flow",
),
),
@ -71,7 +71,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_recovery",
related_name="brand_recovery",
to="authentik_flows.flow",
),
),
@ -80,23 +80,23 @@ class Migration(migrations.Migration):
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_unenrollment",
related_name="brand_unenrollment",
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "Tenant",
"verbose_name_plural": "Tenants",
"verbose_name": "Brand",
"verbose_name_plural": "Brands",
},
),
migrations.AddField(
model_name="tenant",
model_name="brand",
name="branding_favicon",
field=models.TextField(default="/static/dist/assets/icons/icon.png"),
),
migrations.AddField(
model_name="tenant",
model_name="brand",
name="event_retention",
field=models.TextField(
default="days=365",
@ -108,7 +108,7 @@ class Migration(migrations.Migration):
),
),
migrations.AddField(
model_name="tenant",
model_name="brand",
name="web_certificate",
field=models.ForeignKey(
default=None,

View File

@ -8,17 +8,17 @@ class Migration(migrations.Migration):
dependencies = [
("authentik_stages_prompt", "0007_prompt_placeholder_expression"),
("authentik_flows", "0021_auto_20211227_2103"),
("authentik_tenants", "0001_squashed_0005_tenant_web_certificate"),
("authentik_brands", "0001_squashed_0005_tenant_web_certificate"),
]
operations = [
migrations.AddField(
model_name="tenant",
model_name="brand",
name="flow_user_settings",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_user_settings",
related_name="brand_user_settings",
to="authentik_flows.flow",
),
),

View File

@ -5,12 +5,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0002_tenant_flow_user_settings"),
("authentik_brands", "0002_tenant_flow_user_settings"),
]
operations = [
migrations.AddField(
model_name="tenant",
model_name="brand",
name="attributes",
field=models.JSONField(blank=True, default=dict),
),

View File

@ -7,17 +7,17 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0023_flow_denied_action"),
("authentik_tenants", "0003_tenant_attributes"),
("authentik_brands", "0003_tenant_attributes"),
]
operations = [
migrations.AddField(
model_name="tenant",
model_name="brand",
name="flow_device_code",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_device_code",
related_name="brand_device_code",
to="authentik_flows.flow",
),
),

View File

View File

@ -0,0 +1,119 @@
# Generated by Django 4.2.7 on 2023-11-06 19:48
import uuid
import django.db.models.deletion
from django.db import migrations, models
import authentik.lib.utils.time
class Migration(migrations.Migration):
initial = True
operations = [
migrations.CreateModel(
name="Brand",
fields=[
(
"brand_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
(
"domain",
models.TextField(
help_text="Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
),
),
("default", models.BooleanField(default=False)),
("branding_title", models.TextField(default="authentik")),
(
"branding_logo",
models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg"),
),
(
"branding_favicon",
models.TextField(default="/static/dist/assets/icons/icon.png"),
),
(
"event_retention",
models.TextField(
default="days=365",
help_text="Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
("attributes", models.JSONField(blank=True, default=dict)),
(
"flow_authentication",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_authentication",
to="authentik_flows.flow",
),
),
(
"flow_device_code",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_device_code",
to="authentik_flows.flow",
),
),
(
"flow_invalidation",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_invalidation",
to="authentik_flows.flow",
),
),
(
"flow_recovery",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_recovery",
to="authentik_flows.flow",
),
),
(
"flow_unenrollment",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_unenrollment",
to="authentik_flows.flow",
),
),
(
"flow_user_settings",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_user_settings",
to="authentik_flows.flow",
),
),
(
"web_certificate",
models.ForeignKey(
default=None,
help_text="Web Certificate used by the authentik Core webserver.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_crypto.certificatekeypair",
),
),
],
options={
"verbose_name": "Brand",
"verbose_name_plural": "Brands",
},
),
]

View File

@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
"""
Noop migration to make sure that data has been migrated from the old tenant system to this before changing this table any further.
"""
dependencies = [
("authentik_brands", "0001_initial"),
("authentik_tenants", "0005_tenant_to_brand"),
]
operations = []

View File

@ -0,0 +1,94 @@
"""brand models"""
from uuid import uuid4
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
LOGGER = get_logger()
class Brand(SerializerModel):
"""Single brand"""
brand_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
domain = models.TextField(
help_text=_(
"Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
)
)
default = models.BooleanField(
default=False,
)
branding_title = models.TextField(default="authentik")
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
flow_authentication = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_authentication"
)
flow_invalidation = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_invalidation"
)
flow_recovery = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_recovery"
)
flow_unenrollment = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_unenrollment"
)
flow_user_settings = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_user_settings"
)
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
event_retention = models.TextField(
default="days=365",
validators=[timedelta_string_validator],
help_text=_(
"Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2)."
),
)
web_certificate = models.ForeignKey(
CertificateKeyPair,
null=True,
default=None,
on_delete=models.SET_DEFAULT,
help_text=_("Web Certificate used by the authentik Core webserver."),
)
attributes = models.JSONField(default=dict, blank=True)
@property
def serializer(self) -> Serializer:
from authentik.brands.api import BrandSerializer
return BrandSerializer
@property
def default_locale(self) -> str:
"""Get default locale"""
try:
return self.attributes.get("settings", {}).get("locale", "")
# pylint: disable=broad-except
except Exception as exc:
LOGGER.warning("Failed to get default locale", exc=exc)
return ""
def __str__(self) -> str:
if self.default:
return "Default brand"
return f"Brand {self.domain}"
class Meta:
verbose_name = _("Brand")
verbose_name_plural = _("Brands")

View File

@ -1,73 +1,73 @@
"""Test tenants"""
"""Test brands"""
from django.test.client import RequestFactory
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_tenant
from authentik.brands.api import Themes
from authentik.brands.models import Brand
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
from authentik.tenants.api import Themes
from authentik.tenants.models import Tenant
class TestTenants(APITestCase):
"""Test tenants"""
class TestBrands(APITestCase):
"""Test brands"""
def test_current_tenant(self):
"""Test Current tenant API"""
tenant = create_test_tenant()
def test_current_brand(self):
"""Test Current brand API"""
brand = create_test_brand()
self.assertJSONEqual(
self.client.get(reverse("authentik_api:tenant-current")).content.decode(),
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"matched_domain": tenant.domain,
"ui_footer_links": CONFIG.get("footer_links"),
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
},
)
def test_tenant_subdomain(self):
"""Test Current tenant API"""
Tenant.objects.all().delete()
Tenant.objects.create(domain="bar.baz", branding_title="custom")
def test_brand_subdomain(self):
"""Test Current brand API"""
Brand.objects.all().delete()
Brand.objects.create(domain="bar.baz", branding_title="custom")
self.assertJSONEqual(
self.client.get(
reverse("authentik_api:tenant-current"), HTTP_HOST="foo.bar.baz"
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom",
"matched_domain": "bar.baz",
"ui_footer_links": CONFIG.get("footer_links"),
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
},
)
def test_fallback(self):
"""Test fallback tenant"""
Tenant.objects.all().delete()
"""Test fallback brand"""
Brand.objects.all().delete()
self.assertJSONEqual(
self.client.get(reverse("authentik_api:tenant-current")).content.decode(),
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"matched_domain": "fallback",
"ui_footer_links": CONFIG.get("footer_links"),
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
},
)
def test_event_retention(self):
"""Test tenant's event retention"""
tenant = Tenant.objects.create(
"""Test brand's event retention"""
brand = Brand.objects.create(
domain="foo",
default=True,
branding_title="custom",
@ -75,7 +75,7 @@ class TestTenants(APITestCase):
)
factory = RequestFactory()
request = factory.get("/")
request.tenant = tenant
request.brand = brand
event = Event.new(action=EventAction.SYSTEM_EXCEPTION, message="test").from_http(request)
self.assertEqual(event.expires.day, (event.created + timedelta_from_string("weeks=3")).day)
self.assertEqual(
@ -87,8 +87,8 @@ class TestTenants(APITestCase):
)
def test_create_default_multiple(self):
"""Test attempted creation of multiple default tenants"""
Tenant.objects.create(
"""Test attempted creation of multiple default brands"""
Brand.objects.create(
domain="foo",
default=True,
branding_title="custom",
@ -97,6 +97,6 @@ class TestTenants(APITestCase):
user = create_test_admin_user()
self.client.force_login(user)
response = self.client.post(
reverse("authentik_api:tenant-list"), data={"domain": "bar", "default": True}
reverse("authentik_api:brand-list"), data={"domain": "bar", "default": True}
)
self.assertEqual(response.status_code, 400)

6
authentik/brands/urls.py Normal file
View File

@ -0,0 +1,6 @@
"""API URLs"""
from authentik.brands.api import BrandViewSet
api_urlpatterns = [
("core/brands", BrandViewSet),
]

43
authentik/brands/utils.py Normal file
View File

@ -0,0 +1,43 @@
"""Brand utilities"""
from typing import Any
from django.db.models import F, Q
from django.db.models import Value as V
from django.http.request import HttpRequest
from sentry_sdk.hub import Hub
from authentik import get_full_version
from authentik.brands.models import Brand
from authentik.lib.config import CONFIG
from authentik.tenants.utils import get_current_tenant
_q_default = Q(default=True)
DEFAULT_BRAND = Brand(domain="fallback")
def get_brand_for_request(request: HttpRequest) -> Brand:
"""Get brand object for current request"""
db_brands = (
Brand.objects.annotate(host_domain=V(request.get_host()))
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
.order_by("default")
)
brands = list(db_brands.all())
if len(brands) < 1:
return DEFAULT_BRAND
return brands[0]
def context_processor(request: HttpRequest) -> dict[str, Any]:
"""Context Processor that injects brand object into every template"""
brand = getattr(request, "brand", DEFAULT_BRAND)
trace = ""
span = Hub.current.scope.span
if span:
trace = span.to_traceparent()
return {
"brand": brand,
"footer_links": get_current_tenant().footer_links,
"sentry_trace": trace,
"version": get_full_version(),
}

View File

@ -56,6 +56,7 @@ from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.api.decorators import permission_required
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
from authentik.core.middleware import (
@ -81,7 +82,6 @@ from authentik.lib.config import CONFIG
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
LOGGER = get_logger()
@ -227,7 +227,7 @@ class UserSelfSerializer(ModelSerializer):
}
def get_settings(self, user: User) -> dict[str, Any]:
"""Get user settings with tenant and group settings applied"""
"""Get user settings with brand and group settings applied"""
return user.group_attributes(self._context["request"]).get("settings", {})
def get_system_permissions(self, user: User) -> list[str]:
@ -388,11 +388,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
return User.objects.all().exclude(pk=get_anonymous_user().pk)
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
"""Create a recovery link (when the current tenant has a recovery flow set),
"""Create a recovery link (when the current brand has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""
tenant: Tenant = self.request._request.tenant
brand: Brand = self.request._request.brand
# Check that there is a recovery flow, if not return an error
flow = tenant.flow_recovery
flow = brand.flow_recovery
if not flow:
LOGGER.debug("No recovery flow set")
return None, None
@ -624,7 +624,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
@action(detail=True, methods=["POST"])
def impersonate(self, request: Request, pk: int) -> Response:
"""Impersonate a user"""
if not CONFIG.get_bool("impersonation"):
if not request.tenant.impersonation:
LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401)
if not request.user.has_perm("impersonate"):

View File

@ -202,8 +202,8 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
"""Get a dictionary containing the attributes from all groups the user belongs to,
including the users attributes"""
final_attributes = {}
if request and hasattr(request, "tenant"):
always_merger.merge(final_attributes, request.tenant.attributes)
if request and hasattr(request, "brand"):
always_merger.merge(final_attributes, request.brand.attributes)
for group in self.all_groups().order_by("name"):
always_merger.merge(final_attributes, group.attributes)
always_merger.merge(final_attributes, self.attributes)
@ -262,7 +262,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
except Exception as exc:
LOGGER.warning("Failed to get default locale", exc=exc)
if request:
return request.tenant.locale
return request.brand.locale
return ""
@property

View File

@ -5,7 +5,7 @@
window.authentik = {
locale: "{{ LANGUAGE_CODE }}",
config: JSON.parse('{{ config_json|escapejs }}'),
tenant: JSON.parse('{{ tenant_json|escapejs }}'),
brand: JSON.parse('{{ brand_json|escapejs }}'),
versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}",

View File

@ -7,9 +7,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon }}">
{% block head_before %}
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">

View File

@ -4,7 +4,7 @@
{% load i18n %}
{% block title %}
{% trans 'End session' %} - {{ tenant.branding_title }}
{% trans 'End session' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
@ -16,7 +16,7 @@ You've logged out of {{ application }}.
{% block card %}
<form method="POST" class="pf-c-form">
<p>
{% blocktrans with application=application.name branding_title=tenant.branding_title %}
{% blocktrans with application=application.name branding_title=brand.branding_title %}
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account.
{% endblocktrans %}
</p>
@ -26,7 +26,7 @@ You've logged out of {{ application }}.
</a>
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
{% blocktrans with branding_title=tenant.branding_title %}
{% blocktrans with branding_title=brand.branding_title %}
Log out of {{ branding_title }}
{% endblocktrans %}
</a>

View File

@ -4,7 +4,7 @@
{% load i18n %}
{% block title %}
{{ tenant.branding_title }}
{{ brand.branding_title }}
{% endblock %}
{% block card_title %}

View File

@ -60,7 +60,7 @@
<div class="ak-login-container">
<header class="pf-c-login__header">
<div class="pf-c-brand ak-brand">
<img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
<img src="{{ brand.branding_logo }}" alt="authentik Logo" />
</div>
</header>
{% block main_container %}

View File

@ -3,10 +3,10 @@ from unittest.mock import MagicMock, patch
from django.urls import reverse
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
from authentik.flows.tests import FlowTestCase
from authentik.tenants.models import Tenant
class TestApplicationsViews(FlowTestCase):
@ -21,9 +21,9 @@ class TestApplicationsViews(FlowTestCase):
def test_check_redirect(self):
"""Test redirect"""
empty_flow = create_test_flow()
tenant: Tenant = create_test_tenant()
tenant.flow_authentication = empty_flow
tenant.save()
brand: Brand = create_test_brand()
brand.flow_authentication = empty_flow
brand.save()
response = self.client.get(
reverse(
"authentik_core:application-launch",
@ -45,9 +45,9 @@ class TestApplicationsViews(FlowTestCase):
"""Test redirect"""
self.client.force_login(self.user)
empty_flow = create_test_flow()
tenant: Tenant = create_test_tenant()
tenant.flow_authentication = empty_flow
tenant.save()
brand: Brand = create_test_brand()
brand.flow_authentication = empty_flow
brand.save()
response = self.client.get(
reverse(
"authentik_core:application-launch",

View File

@ -7,6 +7,7 @@ from django.core.cache import cache
from django.urls.base import reverse
from rest_framework.test import APITestCase
from authentik.brands.models import Brand
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
AuthenticatedSession,
@ -14,11 +15,10 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant
class TestUsersAPI(APITestCase):
@ -80,9 +80,9 @@ class TestUsersAPI(APITestCase):
def test_recovery(self):
"""Test user recovery link (no recovery flow set)"""
flow = create_test_flow(FlowDesignation.RECOVERY)
tenant: Tenant = create_test_tenant()
tenant.flow_recovery = flow
tenant.save()
brand: Brand = create_test_brand()
brand.flow_recovery = flow
brand.save()
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
@ -108,9 +108,9 @@ class TestUsersAPI(APITestCase):
self.user.email = "foo@bar.baz"
self.user.save()
flow = create_test_flow(designation=FlowDesignation.RECOVERY)
tenant: Tenant = create_test_tenant()
tenant.flow_recovery = flow
tenant.save()
brand: Brand = create_test_brand()
brand.flow_recovery = flow
brand.save()
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
@ -122,9 +122,9 @@ class TestUsersAPI(APITestCase):
self.user.email = "foo@bar.baz"
self.user.save()
flow = create_test_flow(FlowDesignation.RECOVERY)
tenant: Tenant = create_test_tenant()
tenant.flow_recovery = flow
tenant.save()
brand: Brand = create_test_brand()
brand.flow_recovery = flow
brand.save()
stage = EmailStage.objects.create(name="email")

View File

@ -3,12 +3,12 @@ from typing import Optional
from django.utils.text import slugify
from authentik.brands.models import Brand
from authentik.core.models import Group, User
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id
from authentik.tenants.models import Tenant
def create_test_flow(
@ -43,12 +43,12 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
return user
def create_test_tenant(**kwargs) -> Tenant:
"""Generate a test tenant, removing all other tenants to make sure this one
def create_test_brand(**kwargs) -> Brand:
"""Generate a test brand, removing all other brands to make sure this one
matches."""
uid = generate_id(20)
Tenant.objects.all().delete()
return Tenant.objects.create(domain=uid, default=True, **kwargs)
Brand.objects.all().delete()
return Brand.objects.create(domain=uid, default=True, **kwargs)
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:

View File

@ -9,8 +9,8 @@ from rest_framework.request import Request
from authentik import get_build_hash
from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.flows.models import Flow
from authentik.tenants.api import CurrentTenantSerializer
class InterfaceView(TemplateView):
@ -18,7 +18,7 @@ class InterfaceView(TemplateView):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
kwargs["tenant_json"] = dumps(CurrentTenantSerializer(self.request.tenant).data)
kwargs["brand_json"] = dumps(CurrentBrandSerializer(self.request.brand).data)
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
kwargs["build"] = get_build_hash()

View File

@ -35,7 +35,7 @@ class EventSerializer(ModelSerializer):
"client_ip",
"created",
"expires",
"tenant",
"brand",
]
@ -76,10 +76,10 @@ class EventsFilter(django_filters.FilterSet):
field_name="action",
lookup_expr="icontains",
)
tenant_name = django_filters.CharFilter(
field_name="tenant",
brand_name = django_filters.CharFilter(
field_name="brand",
lookup_expr="name",
label="Tenant name",
label="Brand name",
)
def filter_context_model_pk(self, queryset, name, value):

View File

@ -305,7 +305,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="event",
name="tenant",
field=models.JSONField(blank=True, default=authentik.events.models.default_tenant),
field=models.JSONField(blank=True, default=authentik.events.models.default_brand),
),
migrations.AlterField(
model_name="event",

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2023-11-06 18:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0002_alter_notificationtransport_mode"),
]
operations = [
migrations.RenameField(
model_name="event",
old_name="tenant",
new_name="brand",
),
]

View File

@ -21,6 +21,8 @@ from requests import RequestException
from structlog.stdlib import get_logger
from authentik import get_full_version
from authentik.brands.models import Brand
from authentik.brands.utils import DEFAULT_BRAND
from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER,
@ -40,8 +42,6 @@ from authentik.lib.utils.http import get_client_ip, get_http_session
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
from authentik.tenants.utils import DEFAULT_TENANT
LOGGER = get_logger()
if TYPE_CHECKING:
@ -50,13 +50,13 @@ if TYPE_CHECKING:
def default_event_duration():
"""Default duration an Event is saved.
This is used as a fallback when no tenant is available"""
This is used as a fallback when no brand is available"""
return now() + timedelta(days=365)
def default_tenant():
"""Get a default value for tenant"""
return sanitize_dict(model_to_dict(DEFAULT_TENANT))
def default_brand():
"""Get a default value for brand"""
return sanitize_dict(model_to_dict(DEFAULT_BRAND))
class NotificationTransportError(SentryIgnoredException):
@ -170,7 +170,7 @@ class Event(SerializerModel, ExpiringModel):
context = models.JSONField(default=dict, blank=True)
client_ip = models.GenericIPAddressField(null=True)
created = models.DateTimeField(auto_now_add=True)
tenant = models.JSONField(default=default_tenant, blank=True)
brand = models.JSONField(default=default_brand, blank=True)
# Shadow the expires attribute from ExpiringModel to override the default duration
expires = models.DateTimeField(default=default_event_duration)
@ -223,13 +223,13 @@ class Event(SerializerModel, ExpiringModel):
if QS_QUERY in self.context["http_request"]["args"]:
wrapped = self.context["http_request"]["args"][QS_QUERY]
self.context["http_request"]["args"] = cleanse_dict(QueryDict(wrapped))
if hasattr(request, "tenant"):
tenant: Tenant = request.tenant
if hasattr(request, "brand"):
brand: Brand = request.brand
# Because self.created only gets set on save, we can't use it's value here
# hence we set self.created to now and then use it
self.created = now()
self.expires = self.created + timedelta_from_string(tenant.event_retention)
self.tenant = sanitize_dict(model_to_dict(tenant))
self.expires = self.created + timedelta_from_string(brand.event_retention)
self.brand = sanitize_dict(model_to_dict(brand))
if hasattr(request, "user"):
original_user = None
if hasattr(request, "session"):

View File

@ -18,6 +18,7 @@ from authentik.stages.invitation.models import Invitation
from authentik.stages.invitation.signals import invitation_used
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
from authentik.stages.user_write.signals import user_write
from authentik.tenants.utils import get_current_tenant
SESSION_LOGIN_EVENT = "login_event"
@ -93,5 +94,5 @@ def event_post_save_notification(sender, instance: Event, **_):
@receiver(pre_delete, sender=User)
def event_user_pre_delete_cleanup(sender, instance: User, **_):
"""If gdpr_compliance is enabled, remove all the user's events"""
if CONFIG.get_bool("gdpr_compliance", True):
if get_current_tenant().avatars:
gdpr_cleanup.delay(instance.pk)

View File

@ -6,12 +6,12 @@ from django.test import RequestFactory, TestCase
from django.views.debug import SafeExceptionReporterFilter
from guardian.shortcuts import get_anonymous_user
from authentik.brands.models import Brand
from authentik.core.models import Group
from authentik.events.models import Event
from authentik.flows.views.executor import QS_QUERY
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.tenants.models import Tenant
class TestEvents(TestCase):
@ -87,19 +87,19 @@ class TestEvents(TestCase):
},
)
def test_from_http_tenant(self):
"""Test from_http tenant"""
# Test tenant
def test_from_http_brand(self):
"""Test from_http brand"""
# Test brand
request = self.factory.get("/")
tenant = Tenant(domain="test-tenant")
setattr(request, "tenant", tenant)
brand = Brand(domain="test-brand")
setattr(request, "brand", brand)
event = Event.new("unittest").from_http(request)
self.assertEqual(
event.tenant,
event.brand,
{
"app": "authentik_tenants",
"model_name": "tenant",
"name": "Tenant test-tenant",
"pk": tenant.pk.hex,
"app": "authentik_brands",
"model_name": "brand",
"name": "Brand test-brand",
"pk": brand.pk.hex,
},
)

View File

@ -22,6 +22,7 @@ from sentry_sdk.api import set_tag
from sentry_sdk.hub import Hub
from structlog.stdlib import BoundLogger, get_logger
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.events.models import Event, EventAction, cleanse_dict
from authentik.flows.apps import HIST_FLOW_EXECUTION_STAGE_TIME
@ -60,7 +61,6 @@ from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
from authentik.policies.engine import PolicyEngine
from authentik.tenants.models import Tenant
LOGGER = get_logger()
# Argument used to redirect user after login
@ -490,11 +490,11 @@ class ToDefaultFlow(View):
def get_flow(self) -> Flow:
"""Get a flow for the selected designation"""
tenant: Tenant = self.request.tenant
brand: Brand = self.request.brand
flow = None
# First, attempt to get default flow from tenant
# First, attempt to get default flow from brand
if self.designation == FlowDesignation.AUTHENTICATION:
flow = tenant.flow_authentication
flow = brand.flow_authentication
# Check if we have a default flow from application
application: Optional[Application] = self.request.session.get(
SESSION_KEY_APPLICATION_PRE
@ -502,7 +502,7 @@ class ToDefaultFlow(View):
if application and application.provider and application.provider.authentication_flow:
flow = application.provider.authentication_flow
elif self.designation == FlowDesignation.INVALIDATION:
flow = tenant.flow_invalidation
flow = brand.flow_invalidation
if flow:
return flow
# If no flow was set, get the first based on slug and policy

View File

@ -13,6 +13,7 @@ from requests.exceptions import RequestException
from authentik.lib.config import CONFIG, get_path_from_dict
from authentik.lib.utils.http import get_http_session
from authentik.tenants.utils import get_current_tenant
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
@ -183,7 +184,7 @@ def get_avatar(user: "User") -> str:
"initials": avatar_mode_generated,
"gravatar": avatar_mode_gravatar,
}
modes: str = CONFIG.get("avatars", "none")
modes: str = get_current_tenant().avatars
for mode in modes.split(","):
avatar = None
if mode in mode_map:

View File

@ -35,8 +35,8 @@ redis:
tls_reqs: "none"
# broker:
# url: ""
# transport_options: ""
# url: ""
# transport_options: ""
cache:
# url: ""
@ -46,10 +46,10 @@ cache:
timeout_reputation: 300
# channel:
# url: ""
# url: ""
# result_backend:
# url: ""
# url: ""
paths:
media: ./media
@ -105,19 +105,12 @@ reputation:
cookie_domain: null
disable_update_check: false
disable_startup_analytics: false
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar,initials
geoip: "/geoip/GeoLite2-City.mmdb"
footer_links: []
default_user_change_name: true
default_user_change_email: false
default_user_change_username: false
gdpr_compliance: true
cert_discovery_dir: /certs
default_token_length: 60
impersonation: true
tenant_management_key: ""
blueprints_dir: /blueprints

View File

@ -19,6 +19,7 @@ from structlog.stdlib import get_logger
from authentik import __version__, get_build_hash
from authentik.blueprints.models import ManagedModel
from authentik.brands.models import Brand
from authentik.core.models import (
USER_PATH_SYSTEM_PREFIX,
Provider,
@ -34,7 +35,6 @@ from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string
from authentik.outposts.controllers.k8s.utils import get_namespace
from authentik.tenants.models import Tenant
OUR_VERSION = parse(__version__)
OUTPOST_HELLO_INTERVAL = 10
@ -407,9 +407,9 @@ class Outpost(SerializerModel, ManagedModel):
else:
objects.append(provider)
if self.managed:
for tenant in Tenant.objects.filter(web_certificate__isnull=False):
objects.append(tenant)
objects.append(tenant.web_certificate)
for brand in Brand.objects.filter(web_certificate__isnull=False):
objects.append(brand)
objects.append(brand.web_certificate)
return objects
def __str__(self) -> str:

View File

@ -5,12 +5,12 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_sav
from django.dispatch import receiver
from structlog.stdlib import get_logger
from authentik.brands.models import Brand
from authentik.core.models import Provider
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection
from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save
from authentik.tenants.models import Tenant
LOGGER = get_logger()
UPDATE_TRIGGERING_MODELS = (
@ -18,7 +18,7 @@ UPDATE_TRIGGERING_MODELS = (
OutpostServiceConnection,
Provider,
CertificateKeyPair,
Tenant,
Brand,
)

View File

@ -161,7 +161,7 @@ class Migration(migrations.Migration):
("authentik.stages.user_login", "authentik Stages.User Login"),
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.tenants", "authentik Tenants"),
("authentik.brands", "authentik Brands"),
("authentik.core", "authentik Core"),
("authentik.blueprints", "authentik Blueprints"),
],

View File

@ -67,7 +67,7 @@ class Migration(migrations.Migration):
("authentik.stages.user_login", "authentik Stages.User Login"),
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.tenants", "authentik Tenants"),
("authentik.brands", "authentik Brands"),
("authentik.blueprints", "authentik Blueprints"),
("authentik.core", "authentik Core"),
],

View File

@ -143,7 +143,7 @@ class PasswordPolicy(Policy):
user_inputs.append(request.user.name)
user_inputs.append(request.user.email)
if request.http_request:
user_inputs.append(request.http_request.tenant.branding_title)
user_inputs.append(request.http_request.brand.branding_title)
# Only calculate result for the first 100 characters, as with over 100 char
# long passwords we can be reasonably sure that they'll surpass the score anyways
# See https://github.com/dropbox/zxcvbn#runtime-latency

View File

@ -4,7 +4,7 @@
{% load i18n %}
{% block title %}
{% trans 'Permission denied' %} - {{ tenant.branding_title }}
{% trans 'Permission denied' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}

View File

@ -4,7 +4,7 @@ from urllib.parse import urlencode
from django.urls import reverse
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -28,9 +28,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
self.user = create_test_admin_user()
self.client.force_login(self.user)
self.device_flow = create_test_flow()
self.tenant = create_test_tenant()
self.tenant.flow_device_code = self.device_flow
self.tenant.save()
self.brand = create_test_brand()
self.brand.flow_device_code = self.device_flow
self.brand.save()
def test_device_init(self):
"""Test device init"""
@ -48,8 +48,8 @@ class TesOAuth2DeviceInit(OAuthTestCase):
def test_no_flow(self):
"""Test no flow"""
self.tenant.flow_device_code = None
self.tenant.save()
self.brand.flow_device_code = None
self.brand.save()
res = self.client.get(reverse("authentik_providers_oauth2_root:device-login"))
self.assertEqual(res.status_code, 404)

View File

@ -8,6 +8,7 @@ from rest_framework.exceptions import ErrorDetail
from rest_framework.fields import CharField, IntegerField
from structlog.stdlib import get_logger
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.exceptions import FlowNonApplicableException
@ -26,7 +27,6 @@ from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
)
from authentik.tenants.models import Tenant
LOGGER = get_logger()
QS_KEY_CODE = "code" # nosec
@ -88,10 +88,10 @@ class DeviceEntryView(View):
"""View used to initiate the device-code flow, url entered by endusers"""
def dispatch(self, request: HttpRequest) -> HttpResponse:
tenant: Tenant = request.tenant
device_flow = tenant.flow_device_code
brand: Brand = request.brand
device_flow = brand.flow_device_code
if not device_flow:
LOGGER.info("Tenant has no device code flow configured", tenant=tenant)
LOGGER.info("Brand has no device code flow configured", brand=brand)
return HttpResponse(status=404)
if QS_KEY_CODE in request.GET:
validation = validate_code(request.GET[QS_KEY_CODE], request)

View File

@ -97,7 +97,7 @@ class GitHubUserTeamsView(View):
"created_at": "",
"updated_at": "",
"organization": {
"login": slugify(request.tenant.branding_title),
"login": slugify(request.brand.branding_title),
"id": 1,
"node_id": "",
"url": "",
@ -109,7 +109,7 @@ class GitHubUserTeamsView(View):
"public_members_url": "",
"avatar_url": "",
"description": "",
"name": request.tenant.branding_title,
"name": request.brand.branding_title,
"company": "",
"blog": "",
"location": "",

View File

@ -1,5 +1,5 @@
"""authentik database backend"""
from django_prometheus.db.backends.postgresql.base import DatabaseWrapper as BaseDatabaseWrapper
from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper
from authentik.lib.config import CONFIG

View File

@ -1,6 +1,7 @@
"""root settings for authentik"""
import importlib
import os
from collections import OrderedDict
from hashlib import sha512
from pathlib import Path
from urllib.parse import quote_plus
@ -49,14 +50,24 @@ AUTHENTICATION_BACKENDS = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Application definition
INSTALLED_APPS = [
SHARED_APPS = [
"django_tenants",
"authentik.tenants",
"daphne",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"rest_framework",
"django_filters",
"drf_spectacular",
"django_prometheus",
"channels",
]
TENANT_APPS = [
"django.contrib.auth",
"django.contrib.sessions",
"authentik.tenants",
"authentik.admin",
"authentik.api",
"authentik.crypto",
@ -102,16 +113,14 @@ INSTALLED_APPS = [
"authentik.stages.user_login",
"authentik.stages.user_logout",
"authentik.stages.user_write",
"authentik.tenants",
"authentik.brands",
"authentik.blueprints",
"rest_framework",
"django_filters",
"drf_spectacular",
"guardian",
"django_prometheus",
"channels",
]
TENANT_MODEL = "authentik_tenants.Tenant"
TENANT_DOMAIN_MODEL = "authentik_tenants.Domain"
GUARDIAN_MONKEY_PATCH = False
SPECTACULAR_SETTINGS = {
@ -214,12 +223,14 @@ SESSION_EXPIRE_AT_BROWSER_CLOSE = True
MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage"
MIDDLEWARE = [
"django_tenants.middleware.default.DefaultTenantMiddleware",
"authentik.root.middleware.LoggingMiddleware",
"django_prometheus.middleware.PrometheusBeforeMiddleware",
"authentik.brands.middleware.TenantMiddleware",
"authentik.root.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"authentik.core.middleware.RequestIDMiddleware",
"authentik.tenants.middleware.TenantMiddleware",
"authentik.brands.middleware.BrandMiddleware",
"authentik.events.middleware.AuditMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
@ -243,7 +254,7 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"authentik.tenants.utils.context_processor",
"authentik.brands.utils.context_processor",
],
},
},
@ -265,6 +276,7 @@ CHANNEL_LAYERS = {
# Database
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql"
DATABASES = {
"default": {
"ENGINE": "authentik.root.db",
@ -289,6 +301,8 @@ if CONFIG.get_bool("postgresql.use_pgbouncer", False):
# https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections
DATABASES["default"]["CONN_MAX_AGE"] = None # persistent
DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",)
# Email
# These values should never actually be used, emails are only sent from email stages, which
# loads the config directly from CONFIG
@ -378,6 +392,8 @@ LOGGING = get_logger_config()
_DISALLOWED_ITEMS = [
"SHARED_APPS",
"TENANT_APPS",
"INSTALLED_APPS",
"MIDDLEWARE",
"AUTHENTICATION_BACKENDS",
@ -389,7 +405,8 @@ def _update_settings(app_path: str):
try:
settings_module = importlib.import_module(app_path)
CONFIG.log("debug", "Loaded app settings", path=app_path)
INSTALLED_APPS.extend(getattr(settings_module, "INSTALLED_APPS", []))
SHARED_APPS.extend(getattr(settings_module, "SHARED_APPS", []))
TENANT_APPS.extend(getattr(settings_module, "TENANT_APPS", []))
MIDDLEWARE.extend(getattr(settings_module, "MIDDLEWARE", []))
AUTHENTICATION_BACKENDS.extend(getattr(settings_module, "AUTHENTICATION_BACKENDS", []))
CELERY["beat_schedule"].update(getattr(settings_module, "CELERY_BEAT_SCHEDULE", {}))
@ -401,7 +418,7 @@ def _update_settings(app_path: str):
# Load subapps's settings
for _app in INSTALLED_APPS:
for _app in set(SHARED_APPS + TENANT_APPS):
if not _app.startswith("authentik"):
continue
_update_settings(f"{_app}.settings")
@ -410,14 +427,14 @@ _update_settings("data.user_settings")
if DEBUG:
CELERY["task_always_eager"] = True
os.environ[ENV_GIT_HASH_KEY] = "dev"
INSTALLED_APPS.append("silk")
SHARED_APPS.append("silk")
SILKY_PYTHON_PROFILER = True
MIDDLEWARE = ["silk.middleware.SilkyMiddleware"] + MIDDLEWARE
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append(
"rest_framework.renderers.BrowsableAPIRenderer"
)
INSTALLED_APPS.append("authentik.core")
TENANT_APPS.append("authentik.core")
CONFIG.log("info", "Booting authentik", version=__version__)
@ -425,7 +442,10 @@ CONFIG.log("info", "Booting authentik", version=__version__)
try:
importlib.import_module("authentik.enterprise.apps")
CONFIG.log("info", "Enabled authentik enterprise")
INSTALLED_APPS.append("authentik.enterprise")
TENANT_APPS.append("authentik.enterprise")
_update_settings("authentik.enterprise.settings")
except ImportError:
pass
SHARED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))
INSTALLED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))

View File

@ -56,7 +56,7 @@ class AuthenticatorTOTPStageView(ChallengeStageView):
data={
"type": ChallengeTypes.NATIVE.value,
"config_url": device.config_url.replace(
OTP_TOTP_ISSUER, quote(self.request.tenant.branding_title)
OTP_TOTP_ISSUER, quote(self.request.brand.branding_title)
),
}
)

View File

@ -201,7 +201,7 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) ->
type=__(
"%(brand_name)s Login request"
% {
"brand_name": stage_view.request.tenant.branding_title,
"brand_name": stage_view.request.brand.branding_title,
}
),
display_username=user.username,

View File

@ -6,6 +6,7 @@ from django.test.client import RequestFactory
from django.urls import reverse
from rest_framework.exceptions import ValidationError
from authentik.brands.utils import get_brand_for_request
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction
from authentik.flows.models import FlowDesignation, FlowStageBinding
@ -19,7 +20,6 @@ from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, Duo
from authentik.stages.authenticator_validate.challenge import validate_challenge_duo
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.user_login.models import UserLoginStage
from authentik.tenants.utils import get_tenant_for_request
class AuthenticatorValidateStageDuoTests(FlowTestCase):
@ -36,7 +36,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
setattr(request, "tenant", get_tenant_for_request(request))
setattr(request, "brand", get_brand_for_request(request))
stage = AuthenticatorDuoStage.objects.create(
name=generate_id(),

View File

@ -89,7 +89,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
rp_id=get_rp_id(self.request),
rp_name=self.request.tenant.branding_title,
rp_name=self.request.brand.branding_title,
user_id=user.uid,
user_name=user.username,
user_display_name=user.name,

View File

@ -1,137 +1,114 @@
"""Serializer for tenant models"""
from typing import Any
"""Serializer for tenants models"""
from hmac import compare_digest
from django.db import models
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ChoiceField, ListField
from django_tenants.utils import get_tenant
from rest_framework import permissions
from rest_framework.authentication import get_authorization_header
from rest_framework.fields import ReadOnlyField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny
from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.views import View
from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import SecretKeyFilter
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
from authentik.api.authentication import validate_auth
from authentik.lib.config import CONFIG
from authentik.tenants.models import Tenant
from authentik.tenants.models import Domain, Tenant
class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API"""
href = CharField(read_only=True)
name = CharField(read_only=True)
class TenantManagementKeyPermission(permissions.BasePermission):
def has_permission(self, request: Request, view: View) -> bool:
token = validate_auth(get_authorization_header(request))
tenant_management_key = CONFIG.get("tenant_management_key")
if compare_digest("", tenant_management_key):
return False
return compare_digest(token, tenant_management_key)
class TenantSerializer(ModelSerializer):
"""Tenant Serializer"""
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
if attrs.get("default", False):
tenants = Tenant.objects.filter(default=True)
if self.instance:
tenants = tenants.exclude(pk=self.instance.pk)
if tenants.exists():
raise ValidationError({"default": "Only a single Tenant can be set as default."})
return super().validate(attrs)
class Meta:
model = Tenant
fields = [
"tenant_uuid",
"domain",
"default",
"branding_title",
"branding_logo",
"branding_favicon",
"flow_authentication",
"flow_invalidation",
"flow_recovery",
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"event_retention",
"web_certificate",
"attributes",
"schema_name",
"name",
]
class Themes(models.TextChoices):
"""Themes"""
AUTOMATIC = "automatic"
LIGHT = "light"
DARK = "dark"
class CurrentTenantSerializer(PassiveSerializer):
"""Partial tenant information for styling"""
matched_domain = CharField(source="domain")
branding_title = CharField()
branding_logo = CharField()
branding_favicon = CharField()
ui_footer_links = ListField(
child=FooterLinkSerializer(),
read_only=True,
default=CONFIG.get("footer_links", []),
)
ui_theme = ChoiceField(
choices=Themes.choices,
source="attributes.settings.theme.base",
default=Themes.AUTOMATIC,
read_only=True,
)
flow_authentication = CharField(source="flow_authentication.slug", required=False)
flow_invalidation = CharField(source="flow_invalidation.slug", required=False)
flow_recovery = CharField(source="flow_recovery.slug", required=False)
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
flow_device_code = CharField(source="flow_device_code.slug", required=False)
default_locale = CharField(read_only=True)
class TenantViewSet(UsedByMixin, ModelViewSet):
class TenantViewSet(ModelViewSet):
"""Tenant Viewset"""
queryset = Tenant.objects.all()
serializer_class = TenantSerializer
search_fields = [
"domain",
"branding_title",
"web_certificate__name",
"name",
"schema_name",
"domains__domain",
]
filterset_fields = [
"tenant_uuid",
ordering = ["schema_name"]
permission_classes = [TenantManagementKeyPermission]
filter_backends = [OrderingFilter, SearchFilter]
class DomainSerializer(ModelSerializer):
"""Domain Serializer"""
class Meta:
model = Domain
fields = "__all__"
class DomainViewSet(ModelViewSet):
"""Domain ViewSet"""
queryset = Domain.objects.all()
serializer_class = DomainSerializer
search_fields = [
"domain",
"default",
"branding_title",
"branding_logo",
"branding_favicon",
"flow_authentication",
"flow_invalidation",
"flow_recovery",
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"event_retention",
"web_certificate",
"tenant__name",
"tenant__schema_name",
]
ordering = ["domain"]
permission_classes = [TenantManagementKeyPermission]
filter_backends = [OrderingFilter, SearchFilter]
filter_backends = [SecretKeyFilter, OrderingFilter, SearchFilter]
@extend_schema(
responses=CurrentTenantSerializer(many=False),
)
@action(methods=["GET"], detail=False, permission_classes=[AllowAny])
def current(self, request: Request) -> Response:
"""Get current tenant"""
tenant: Tenant = request._request.tenant
return Response(CurrentTenantSerializer(tenant).data)
class SettingsSerializer(ModelSerializer):
"""Settings Serializer"""
name = ReadOnlyField()
domains = DomainSerializer(read_only=True, many=True)
class Meta:
model = Tenant
fields = [
"tenant_uuid",
"name",
"domains",
"avatars",
"default_user_change_name",
"default_user_change_email",
"default_user_change_username",
"gdpr_compliance",
"impersonation",
"footer_links",
"reputation_expiry",
]
class SettingsView(RetrieveUpdateAPIView):
"""Settings view"""
queryset = Tenant.objects.all()
serializer_class = SettingsSerializer
permission_classes = [IsAdminUser]
filter_backends = []
def get_object(self):
obj = get_tenant(self.request)
self.check_object_permissions(obj)
return obj

View File

@ -1,9 +1,9 @@
"""authentik tenant app"""
"""authentik tenants app"""
from django.apps import AppConfig
class AuthentikTenantsConfig(AppConfig):
"""authentik Tenant app"""
"""authentik tenants app"""
name = "authentik.tenants"
label = "authentik_tenants"

View File

@ -1,15 +1,13 @@
"""Inject tenant into current request"""
from typing import Callable
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.utils.translation import activate
from django.http import HttpRequest, HttpResponse
from sentry_sdk.api import set_tag
from authentik.tenants.utils import get_tenant_for_request
class TenantMiddleware:
class CurrentTenantMiddleware:
"""Add current tenant to http request"""
get_response: Callable[[HttpRequest], HttpResponse]
@ -22,8 +20,5 @@ class TenantMiddleware:
tenant = get_tenant_for_request(request)
setattr(request, "tenant", tenant)
set_tag("authentik.tenant_uuid", tenant.tenant_uuid.hex)
set_tag("authentik.tenant_domain", tenant.domain)
locale = tenant.default_locale
if locale != "":
activate(locale)
set_tag("authentik.tenant_domain_regex", tenant.domain_regex)
return self.get_response(request)

View File

@ -0,0 +1,144 @@
# Generated by Django 4.2.7 on 2023-11-15 10:53
import uuid
import django.db.models.deletion
import django_tenants.postgresql_backend.base
from django.db import migrations, models
from authentik.lib.config import CONFIG
def create_default_tenant(apps, schema_editor):
db_alias = schema_editor.connection.alias
Tenant = apps.get_model("authentik_tenants", "Tenant")
tenant = Tenant.objects.using(db_alias).create(
schema_name="public",
name="Default",
avatars=CONFIG.get("avatars", "gravatar,initials"),
default_user_change_name=CONFIG.get_bool("default_user_change_name", True),
default_user_change_email=CONFIG.get_bool("default_user_change_email", False),
default_user_change_username=CONFIG.get_bool("default_user_change_username", False),
gdpr_compliance=CONFIG.get_bool("gdpr_compliance", True),
impersonation=CONFIG.get_bool("impersonation", True),
footer_links=CONFIG.get("footer_links", default=[]),
)
Domain = apps.get_model("authentik_tenants", "Domain")
domain = Domain.objects.using(db_alias).create(domain="*", tenant=tenant, is_primary=True)
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Tenant",
fields=[
(
"schema_name",
models.CharField(
db_index=True,
max_length=63,
unique=True,
validators=[django_tenants.postgresql_backend.base._check_schema_name],
),
),
(
"tenant_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("name", models.TextField()),
(
"avatars",
models.TextField(
default="gravatar,initials",
help_text="Configure how authentik should show avatars for users.",
),
),
(
"default_user_change_name",
models.BooleanField(
default=True, help_text="Enable the ability for users to change their name."
),
),
(
"default_user_change_email",
models.BooleanField(
default=False,
help_text="Enable the ability for users to change their email address.",
),
),
(
"default_user_change_username",
models.BooleanField(
default=False,
help_text="Enable the ability for users to change their username.",
),
),
(
"gdpr_compliance",
models.BooleanField(
default=True,
help_text="When enabled, all the events caused by a user will be deleted upon the user's deletion.",
),
),
(
"impersonation",
models.BooleanField(
default=True, help_text="Globally enable/disable impersonation."
),
),
(
"footer_links",
models.JSONField(
blank=True,
default=list,
help_text="The option configures the footer links on the flow executor pages.",
),
),
(
"reputation_expiry",
models.PositiveBigIntegerField(
default=86400,
help_text="Configure how long reputation scores should be saved for in seconds.",
),
),
],
options={
"verbose_name": "Tenant",
"verbose_name_plural": "Tenants",
},
),
migrations.CreateModel(
name="Domain",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("domain", models.CharField(db_index=True, max_length=253, unique=True)),
("is_primary", models.BooleanField(db_index=True, default=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="domains",
to="authentik_tenants.tenant",
),
),
],
options={
"verbose_name": "Domain",
"verbose_name_plural": "Domains",
},
),
migrations.RunPython(code=create_default_tenant, reverse_code=migrations.RunPython.noop),
]

View File

@ -1,72 +1,60 @@
"""tenant models"""
"""Tenant models"""
from uuid import uuid4
from django.db import models
from django.db.models.deletion import ProtectedError
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django_tenants.models import DomainMixin, TenantMixin
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
LOGGER = get_logger()
class Tenant(SerializerModel):
"""Single tenant"""
class Tenant(TenantMixin, SerializerModel):
"""Tenant"""
tenant_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
domain = models.TextField(
name = models.TextField()
auto_create_schema = True
auto_drop_schema = True
avatars = models.TextField(
help_text=_("Configure how authentik should show avatars for users."),
default="gravatar,initials",
)
default_user_change_name = models.BooleanField(
help_text=_("Enable the ability for users to change their name."), default=True
)
default_user_change_email = models.BooleanField(
help_text=_("Enable the ability for users to change their email address."), default=False
)
default_user_change_username = models.BooleanField(
help_text=_("Enable the ability for users to change their username."), default=False
)
gdpr_compliance = models.BooleanField(
help_text=_(
"Domain that activates this tenant. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
)
)
default = models.BooleanField(
default=False,
)
branding_title = models.TextField(default="authentik")
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
flow_authentication = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_authentication"
)
flow_invalidation = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_invalidation"
)
flow_recovery = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_recovery"
)
flow_unenrollment = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_unenrollment"
)
flow_user_settings = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_user_settings"
)
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_device_code"
)
event_retention = models.TextField(
default="days=365",
validators=[timedelta_string_validator],
help_text=_(
"Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2)."
"When enabled, all the events caused by a user will be deleted upon the user's deletion."
),
default=True,
)
web_certificate = models.ForeignKey(
CertificateKeyPair,
null=True,
default=None,
on_delete=models.SET_DEFAULT,
help_text=_("Web Certificate used by the authentik Core webserver."),
impersonation = models.BooleanField(
help_text=_("Globally enable/disable impersonation."), default=True
)
footer_links = models.JSONField(
help_text=_("The option configures the footer links on the flow executor pages."),
default=list,
blank=True,
)
reputation_expiry = models.PositiveBigIntegerField(
help_text=_("Configure how long reputation scores should be saved for in seconds."),
default=86400,
)
attributes = models.JSONField(default=dict, blank=True)
@property
def serializer(self) -> Serializer:
@ -74,21 +62,24 @@ class Tenant(SerializerModel):
return TenantSerializer
@property
def default_locale(self) -> str:
"""Get default locale"""
try:
return self.attributes.get("settings", {}).get("locale", "")
# pylint: disable=broad-except
except Exception as exc:
LOGGER.warning("Failed to get default locale", exc=exc)
return ""
def __str__(self) -> str:
if self.default:
return "Default tenant"
return f"Tenant {self.domain}"
return f"Tenant {self.domain_regex}"
class Meta:
verbose_name = _("Tenant")
verbose_name_plural = _("Tenants")
class Domain(DomainMixin, SerializerModel):
def __str__(self) -> str:
return f"Domain {self.domain}"
@property
def serializer(self) -> Serializer:
from authentik.tenants.api import DomainSerializer
return DomainSerializer
class Meta:
verbose_name = _("Domain")
verbose_name_plural = _("Domains")

View File

@ -1,6 +1,12 @@
"""API URLs"""
from authentik.tenants.api import TenantViewSet
from django.urls import path
from authentik.tenants.api import SettingsView, TenantViewSet
api_urlpatterns = [
("core/tenants", TenantViewSet),
path("admin/settings/", SettingsView.as_view(), name="tenant_settings"),
(
"tenants",
TenantViewSet,
),
]

View File

@ -1,42 +1,8 @@
"""Tenant utilities"""
from typing import Any
from django.db import connection
from django.db.models import F, Q
from django.db.models import Value as V
from django.http.request import HttpRequest
from sentry_sdk.hub import Hub
from authentik import get_full_version
from authentik.lib.config import CONFIG
from authentik.tenants.models import Tenant
_q_default = Q(default=True)
DEFAULT_TENANT = Tenant(domain="fallback")
def get_tenant_for_request(request: HttpRequest) -> Tenant:
"""Get tenant object for current request"""
db_tenants = (
Tenant.objects.annotate(host_domain=V(request.get_host()))
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
.order_by("default")
)
tenants = list(db_tenants.all())
if len(tenants) < 1:
return DEFAULT_TENANT
return tenants[0]
def context_processor(request: HttpRequest) -> dict[str, Any]:
"""Context Processor that injects tenant object into every template"""
tenant = getattr(request, "tenant", DEFAULT_TENANT)
trace = ""
span = Hub.current.scope.span
if span:
trace = span.to_traceparent()
return {
"tenant": tenant,
"footer_links": CONFIG.get("footer_links"),
"sentry_trace": trace,
"version": get_full_version(),
}
def get_current_tenant() -> Tenant:
"""Get tenant for current request"""
return connection.tenant

View File

@ -0,0 +1,31 @@
metadata:
name: Default - Brand
version: 1
entries:
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - Authentication flow
required: false
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - Invalidation flow
required: false
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - User settings flow
required: false
- attrs:
flow_authentication:
!Find [authentik_flows.flow, [slug, default-authentication-flow]]
flow_invalidation:
!Find [authentik_flows.flow, [slug, default-invalidation-flow]]
flow_user_settings:
!Find [authentik_flows.flow, [slug, default-user-settings-flow]]
identifiers:
domain: authentik-default
default: True
state: created
model: authentik_brands.brand

View File

@ -1,28 +0,0 @@
metadata:
name: Default - Tenant
version: 1
entries:
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - Authentication flow
required: false
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - Invalidation flow
required: false
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - User settings flow
required: false
- attrs:
flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]]
identifiers:
domain: authentik-default
default: True
state: created
model: authentik_tenants.tenant

View File

@ -2,148 +2,148 @@ version: 1
metadata:
name: Default - User settings flow
entries:
- attrs:
designation: stage_configuration
name: User settings
title: Update your info
authentication: require_authenticated
identifiers:
slug: default-user-settings-flow
model: authentik_flows.flow
id: flow
- attrs:
order: 200
placeholder: Username
placeholder_expression: false
initial_value: |
try:
return user.username
except:
return ''
initial_value_expression: true
required: true
type: text
field_key: username
label: Username
identifiers:
name: default-user-settings-field-username
id: prompt-field-username
model: authentik_stages_prompt.prompt
- attrs:
order: 201
placeholder: Name
placeholder_expression: false
initial_value: |
try:
return user.name
except:
return ''
initial_value_expression: true
required: true
type: text
field_key: name
label: Name
identifiers:
name: default-user-settings-field-name
id: prompt-field-name
model: authentik_stages_prompt.prompt
- attrs:
order: 202
placeholder: Email
placeholder_expression: false
initial_value: |
try:
return user.email
except:
return ''
initial_value_expression: true
required: true
type: email
field_key: email
label: Email
identifiers:
name: default-user-settings-field-email
id: prompt-field-email
model: authentik_stages_prompt.prompt
- attrs:
order: 203
placeholder: Locale
placeholder_expression: false
initial_value: |
try:
return user.attributes.get("settings", {}).get("locale", "")
except:
return ''
initial_value_expression: true
required: true
type: ak-locale
field_key: attributes.settings.locale
label: Locale
identifiers:
name: default-user-settings-field-locale
id: prompt-field-locale
model: authentik_stages_prompt.prompt
- attrs:
expression: |
from authentik.lib.config import CONFIG
from authentik.core.models import (
USER_ATTRIBUTE_CHANGE_EMAIL,
USER_ATTRIBUTE_CHANGE_NAME,
USER_ATTRIBUTE_CHANGE_USERNAME
)
prompt_data = request.context.get("prompt_data")
- attrs:
designation: stage_configuration
name: User settings
title: Update your info
authentication: require_authenticated
identifiers:
slug: default-user-settings-flow
model: authentik_flows.flow
id: flow
- attrs:
order: 200
placeholder: Username
placeholder_expression: false
initial_value: |
try:
return user.username
except:
return ''
initial_value_expression: true
required: true
type: text
field_key: username
label: Username
identifiers:
name: default-user-settings-field-username
id: prompt-field-username
model: authentik_stages_prompt.prompt
- attrs:
order: 201
placeholder: Name
placeholder_expression: false
initial_value: |
try:
return user.name
except:
return ''
initial_value_expression: true
required: true
type: text
field_key: name
label: Name
identifiers:
name: default-user-settings-field-name
id: prompt-field-name
model: authentik_stages_prompt.prompt
- attrs:
order: 202
placeholder: Email
placeholder_expression: false
initial_value: |
try:
return user.email
except:
return ''
initial_value_expression: true
required: true
type: email
field_key: email
label: Email
identifiers:
name: default-user-settings-field-email
id: prompt-field-email
model: authentik_stages_prompt.prompt
- attrs:
order: 203
placeholder: Locale
placeholder_expression: false
initial_value: |
try:
return user.attributes.get("settings", {}).get("locale", "")
except:
return ''
initial_value_expression: true
required: true
type: ak-locale
field_key: attributes.settings.locale
label: Locale
identifiers:
name: default-user-settings-field-locale
id: prompt-field-locale
model: authentik_stages_prompt.prompt
- attrs:
expression: |
from authentik.lib.config import CONFIG
from authentik.core.models import (
USER_ATTRIBUTE_CHANGE_EMAIL,
USER_ATTRIBUTE_CHANGE_NAME,
USER_ATTRIBUTE_CHANGE_USERNAME
)
prompt_data = request.context.get("prompt_data")
if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.get_bool("default_user_change_email", True)
):
if prompt_data.get("email") != request.user.email:
ak_message("Not allowed to change email address.")
return False
if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_EMAIL, request.tenant.default_user_change_email
):
if prompt_data.get("email") != request.user.email:
ak_message("Not allowed to change email address.")
return False
if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_NAME, CONFIG.get_bool("default_user_change_name", True)
):
if prompt_data.get("name") != request.user.name:
ak_message("Not allowed to change name.")
return False
if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_NAME, request.tenant.default_user_change_name
):
if prompt_data.get("name") != request.user.name:
ak_message("Not allowed to change name.")
return False
if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.get_bool("default_user_change_username", True)
):
if prompt_data.get("username") != request.user.username:
ak_message("Not allowed to change username.")
return False
if not request.user.group_attributes(request.http_request).get(
USER_ATTRIBUTE_CHANGE_USERNAME, request.tenant.default_user_change_username
):
if prompt_data.get("username") != request.user.username:
ak_message("Not allowed to change username.")
return False
return True
identifiers:
name: default-user-settings-authorization
id: default-user-settings-authorization
model: authentik_policies_expression.expressionpolicy
- identifiers:
name: default-user-settings-write
attrs:
user_creation_mode: never_create
id: default-user-settings-write
model: authentik_stages_user_write.userwritestage
- attrs:
fields:
- !KeyOf prompt-field-username
- !KeyOf prompt-field-name
- !KeyOf prompt-field-email
- !KeyOf prompt-field-locale
validation_policies:
- !KeyOf default-user-settings-authorization
identifiers:
name: default-user-settings
id: default-user-settings
model: authentik_stages_prompt.promptstage
- identifiers:
order: 20
stage: !KeyOf default-user-settings
target: !KeyOf flow
model: authentik_flows.flowstagebinding
- identifiers:
order: 100
stage: !KeyOf default-user-settings-write
target: !KeyOf flow
model: authentik_flows.flowstagebinding
return True
identifiers:
name: default-user-settings-authorization
id: default-user-settings-authorization
model: authentik_policies_expression.expressionpolicy
- identifiers:
name: default-user-settings-write
attrs:
user_creation_mode: never_create
id: default-user-settings-write
model: authentik_stages_user_write.userwritestage
- attrs:
fields:
- !KeyOf prompt-field-username
- !KeyOf prompt-field-name
- !KeyOf prompt-field-email
- !KeyOf prompt-field-locale
validation_policies:
- !KeyOf default-user-settings-authorization
identifiers:
name: default-user-settings
id: default-user-settings
model: authentik_stages_prompt.promptstage
- identifiers:
order: 20
stage: !KeyOf default-user-settings
target: !KeyOf flow
model: authentik_flows.flowstagebinding
- identifiers:
order: 100
stage: !KeyOf default-user-settings-write
target: !KeyOf flow
model: authentik_flows.flowstagebinding

View File

@ -41,6 +41,80 @@
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_tenants.tenant"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_tenants.tenant"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_tenants.tenant"
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_tenants.domain"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_tenants.domain"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_tenants.domain"
}
}
},
{
"type": "object",
"required": [
@ -2528,7 +2602,7 @@
],
"properties": {
"model": {
"const": "authentik_tenants.tenant"
"const": "authentik_brands.brand"
},
"id": {
"type": "string"
@ -2550,10 +2624,10 @@
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_tenants.tenant"
"$ref": "#/$defs/model_authentik_brands.brand"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_tenants.tenant"
"$ref": "#/$defs/model_authentik_brands.brand"
}
}
},
@ -2821,6 +2895,43 @@
}
},
"$defs": {
"model_authentik_tenants.tenant": {
"type": "object",
"properties": {
"schema_name": {
"type": "string",
"maxLength": 63,
"minLength": 1,
"title": "Schema name"
},
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
}
},
"required": []
},
"model_authentik_tenants.domain": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"maxLength": 253,
"minLength": 1,
"title": "Domain"
},
"is_primary": {
"type": "boolean",
"title": "Is primary"
},
"tenant": {
"type": "integer",
"title": "Tenant"
}
},
"required": []
},
"model_authentik_crypto.certificatekeypair": {
"type": "object",
"properties": {
@ -2907,10 +3018,10 @@
"format": "date-time",
"title": "Expires"
},
"tenant": {
"brand": {
"type": "object",
"additionalProperties": true,
"title": "Tenant"
"title": "Brand"
}
},
"required": []
@ -3016,10 +3127,10 @@
"format": "date-time",
"title": "Expires"
},
"tenant": {
"brand": {
"type": "object",
"additionalProperties": true,
"title": "Tenant"
"title": "Brand"
}
},
"required": [
@ -3427,6 +3538,7 @@
],
"enum": [
null,
"authentik.tenants",
"authentik.admin",
"authentik.api",
"authentik.crypto",
@ -3472,7 +3584,7 @@
"authentik.stages.user_login",
"authentik.stages.user_logout",
"authentik.stages.user_write",
"authentik.tenants",
"authentik.brands",
"authentik.blueprints",
"authentik.core",
"authentik.enterprise"
@ -3487,6 +3599,8 @@
],
"enum": [
null,
"authentik_tenants.tenant",
"authentik_tenants.domain",
"authentik_crypto.certificatekeypair",
"authentik_events.event",
"authentik_events.notificationtransport",
@ -3554,7 +3668,7 @@
"authentik_stages_user_login.userloginstage",
"authentik_stages_user_logout.userlogoutstage",
"authentik_stages_user_write.userwritestage",
"authentik_tenants.tenant",
"authentik_brands.brand",
"authentik_blueprints.blueprintinstance",
"authentik_core.group",
"authentik_core.user",
@ -8390,14 +8504,14 @@
},
"required": []
},
"model_authentik_tenants.tenant": {
"model_authentik_brands.brand": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"minLength": 1,
"title": "Domain",
"description": "Domain that activates this tenant. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
"description": "Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
},
"default": {
"type": "boolean",

View File

@ -19,7 +19,7 @@ import (
sentryutils "goauthentik.io/internal/utils/sentry"
webutils "goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web"
"goauthentik.io/internal/web/tenant_tls"
"goauthentik.io/internal/web/brand_tls"
)
var rootCmd = &cobra.Command{
@ -95,11 +95,11 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) {
}
continue
}
// Init tenant_tls here too since it requires an API Client,
// Init brand_tls here too since it requires an API Client,
// so we just reuse the same one as the outpost uses
tw := tenant_tls.NewWatcher(ac.Client)
tw := brand_tls.NewWatcher(ac.Client)
go tw.Start()
ws.TenantTLS = tw
ws.BrandTLS = tw
ac.AddRefreshHandler(func() {
tw.Check()
})

View File

@ -30,9 +30,9 @@ func (ls *LDAPServer) getCurrentProvider(pk int32) *ProviderInstance {
}
func (ls *LDAPServer) getInvalidationFlow() string {
req, _, err := ls.ac.Client.CoreApi.CoreTenantsCurrentRetrieve(context.Background()).Execute()
req, _, err := ls.ac.Client.CoreApi.CoreBrandsCurrentRetrieve(context.Background()).Execute()
if err != nil {
ls.log.WithError(err).Warning("failed to fetch tenant config")
ls.log.WithError(err).Warning("failed to fetch brand config")
return ""
}
flow := req.GetFlowInvalidation()

View File

@ -1,4 +1,4 @@
package tenant_tls
package brand_tls
import (
"crypto/tls"
@ -6,6 +6,7 @@ import (
"time"
log "github.com/sirupsen/logrus"
"goauthentik.io/api/v3"
"goauthentik.io/internal/crypto"
"goauthentik.io/internal/outpost/ak"
@ -16,12 +17,12 @@ type Watcher struct {
log *log.Entry
cs *ak.CryptoStore
fallback *tls.Certificate
tenants []api.Tenant
brands []api.Brand
}
func NewWatcher(client *api.APIClient) *Watcher {
cs := ak.NewCryptoStore(client.CryptoApi)
l := log.WithField("logger", "authentik.router.tenant_tls")
l := log.WithField("logger", "authentik.router.brand_tls")
cert, err := crypto.GenerateSelfSignedCert()
if err != nil {
l.WithError(err).Error("failed to generate default cert")
@ -37,20 +38,20 @@ func NewWatcher(client *api.APIClient) *Watcher {
func (w *Watcher) Start() {
ticker := time.NewTicker(time.Minute * 3)
w.log.Info("Starting Tenant TLS Checker")
w.log.Info("Starting Brand TLS Checker")
for ; true; <-ticker.C {
w.Check()
}
}
func (w *Watcher) Check() {
w.log.Info("updating tenant certificates")
tenants, _, err := w.client.CoreApi.CoreTenantsListExecute(api.ApiCoreTenantsListRequest{})
w.log.Info("updating brand certificates")
brands, _, err := w.client.CoreApi.CoreBrandsListExecute(api.ApiCoreBrandsListRequest{})
if err != nil {
w.log.WithError(err).Warning("failed to get tenants")
w.log.WithError(err).Warning("failed to get brands")
return
}
for _, t := range tenants.Results {
for _, t := range brands.Results {
if kp := t.WebCertificate.Get(); kp != nil {
err := w.cs.AddKeypair(*kp)
if err != nil {
@ -58,12 +59,12 @@ func (w *Watcher) Check() {
}
}
}
w.tenants = tenants.Results
w.brands = brands.Results
}
func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, error) {
var bestSelection *api.Tenant
for _, t := range w.tenants {
var bestSelection *api.Brand
for _, t := range w.brands {
if t.WebCertificate.Get() == nil {
continue
}

View File

@ -5,6 +5,7 @@ import (
"net"
"github.com/pires/go-proxyproto"
"goauthentik.io/internal/config"
"goauthentik.io/internal/crypto"
"goauthentik.io/internal/utils"
@ -26,8 +27,8 @@ func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Certif
return appCert, nil
}
}
if ws.TenantTLS != nil {
return ws.TenantTLS.GetCertificate(ch)
if ws.BrandTLS != nil {
return ws.BrandTLS.GetCertificate(ch)
}
ws.log.Trace("using default, self-signed certificate")
return &cert, nil

View File

@ -15,11 +15,12 @@ import (
"github.com/gorilla/mux"
"github.com/pires/go-proxyproto"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/gounicorn"
"goauthentik.io/internal/outpost/proxyv2"
"goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web/tenant_tls"
"goauthentik.io/internal/web/brand_tls"
)
type WebServer struct {
@ -29,7 +30,7 @@ type WebServer struct {
stop chan struct{} // channel for waiting shutdown
ProxyServer *proxyv2.ProxyServer
TenantTLS *tenant_tls.Watcher
BrandTLS *brand_tls.Watcher
g *gounicorn.GoUnicorn
gr bool

View File

@ -1,4 +1,4 @@
#!/bin/bash -e
#!/usr/bin/env -S bash -e
MODE_FILE="${TMPDIR}/authentik-mode"
function log {

View File

@ -0,0 +1,25 @@
# flake8: noqa
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """
BEGIN TRANSACTION;
ALTER TABLE authentik_tenants_tenant RENAME TO authentik_brands_brand;
UPDATE django_migrations SET app = replace(app, 'authentik_tenants', 'authentik_brands');
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_tenants', 'authentik_brands');
COMMIT;
"""
class Migration(BaseMigration):
def needs_migration(self) -> bool:
self.cur.execute(
"select * from information_schema.tables where table_name =" " 'django_migrations';"
)
# No migration table, assume new installation
if not bool(self.cur.rowcount):
return False
self.cur.execute("select * from django_migrations where app = 'authentik_brands';")
return not bool(self.cur.rowcount)
def run(self):
self.cur.execute(SQL_STATEMENT)

2124
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -133,6 +133,7 @@ django-guardian = "*"
django-model-utils = "*"
django-prometheus = "*"
django-redis = "*"
django-tenants = { git = "https://github.com/hho6643/django-tenants.git", branch="hho6643-psycopg3_fixes" }
djangorestframework = "*"
djangorestframework-guardian = "*"
docker = "*"

1604
schema.yml

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ import Page from "../page.js";
export class LdapForm extends Page {
async setBindFlow(selector: string) {
await this.searchSelect(
'>>>ak-tenanted-flow-search[name="authorizationFlow"] input[type="text"]',
'>>>ak-branded-flow-search[name="authorizationFlow"] input[type="text"]',
"authorizationFlow",
`button*=${selector}`,
);

View File

@ -3,7 +3,7 @@ import Page from "../page.js";
export class RadiusForm extends Page {
async setAuthenticationFlow(selector: string) {
await this.searchSelect(
'>>>ak-tenanted-flow-search[name="authorizationFlow"] input[type="text"]',
'>>>ak-branded-flow-search[name="authorizationFlow"] input[type="text"]',
"authorizationFlow",
`button*=${selector}`,
);

View File

@ -0,0 +1,296 @@
import { ROUTES } from "@goauthentik/admin/Routes";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import {
EVENT_API_DRAWER_TOGGLE,
EVENT_NOTIFICATION_DRAWER_TOGGLE,
EVENT_SIDEBAR_TOGGLE,
VERSION,
} from "@goauthentik/common/constants";
import { configureSentry } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users";
import { WebsocketClient } from "@goauthentik/common/ws";
import { Interface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/enterprise/EnterpriseStatusBanner";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer";
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/RouterOutlet";
import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem";
import { spread } from "@open-wc/lit-helpers";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
AdminApi,
CapabilitiesEnum,
CoreApi,
SessionUser,
UiThemeEnum,
Version,
} from "@goauthentik/api";
@customElement("ak-interface-admin")
export class AdminInterface extends Interface {
@property({ type: Boolean })
sidebarOpen = true;
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@property({ type: Boolean })
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
ws: WebsocketClient;
@state()
version?: Version;
@state()
user?: SessionUser;
static get styles(): CSSResult[] {
return [
PFBase,
PFPage,
PFButton,
PFDrawer,
css`
.pf-c-page__main,
.pf-c-drawer__content,
.pf-c-page__drawer {
z-index: auto !important;
background-color: transparent;
}
.display-none {
display: none;
}
.pf-c-page {
background-color: var(--pf-c-page--BackgroundColor) !important;
}
/* Global page background colour */
:host([theme="dark"]) .pf-c-page {
--pf-c-page--BackgroundColor: var(--ak-dark-background);
}
`,
];
}
constructor() {
super();
this.ws = new WebsocketClient();
this.sidebarOpen = window.innerWidth >= 1280;
window.addEventListener("resize", () => {
this.sidebarOpen = window.innerWidth >= 1280;
});
window.addEventListener(EVENT_SIDEBAR_TOGGLE, () => {
this.sidebarOpen = !this.sidebarOpen;
});
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
});
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen,
});
});
}
async firstUpdated(): Promise<void> {
configureSentry(true);
this.version = await new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve();
this.user = await me();
const canAccessAdmin =
this.user.user.isSuperuser ||
// TODO: somehow add `access_admin_interface` to the API schema
this.user.user.systemPermissions.includes("access_admin_interface");
if (!canAccessAdmin && this.user.user.pk > 0) {
window.location.assign("/if/user/");
}
}
render(): TemplateResult {
return html` <ak-locale-context>
<div class="pf-c-page">
<ak-sidebar
class="pf-c-page__sidebar ${this.sidebarOpen
? "pf-m-expanded"
: "pf-m-collapsed"} ${this.activeTheme === UiThemeEnum.Light
? "pf-m-light"
: ""}"
>
${this.renderSidebarItems()}
</ak-sidebar>
<div class="pf-c-page__drawer">
<div
class="pf-c-drawer ${this.notificationDrawerOpen || this.apiDrawerOpen
? "pf-m-expanded"
: "pf-m-collapsed"}"
>
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<main class="pf-c-page__main">
<ak-router-outlet
role="main"
class="pf-c-page__main"
tabindex="-1"
id="main-content"
defaultUrl="/administration/overview"
.routes=${ROUTES}
>
</ak-router-outlet>
</main>
</div>
</div>
<ak-notification-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this
.notificationDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.notificationDrawerOpen}
></ak-notification-drawer>
<ak-api-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.apiDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.apiDrawerOpen}
></ak-api-drawer>
</div>
</div>
</div></div
></ak-locale-context>`;
}
renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler.
type SidebarEntry = [
path: string | null,
label: string,
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
children?: SidebarEntry[],
];
// prettier-ignore
const sidebarContent: SidebarEntry[] = [
["/if/user/", msg("User interface"), { "?isAbsoluteLink": true, "?highlight": true }],
[null, msg("Dashboards"), { "?expanded": true }, [
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")]]],
[null, msg("Applications"), null, [
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/outpost/outposts", msg("Outposts")]]],
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]],
[null, msg("Customisation"), null, [
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")]]],
[null, msg("Flows and Stages"), null, [
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")]]],
[null, msg("Directory"), null, [
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")]]],
[null, msg("System"), null, [
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")]]]
];
// Typescript requires the type here to correctly type the recursive path
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
const properties = Array.isArray(attributes)
? { ".activeWhen": attributes }
: attributes ?? {};
if (path) {
properties["path"] = path;
}
return html`<ak-sidebar-item ${spread(properties)}>
${label ? html`<span slot="label">${label}</span>` : nothing}
${map(children, renderOneSidebarItem)}
</ak-sidebar-item>`;
};
// prettier-ignore
return html`
${this.renderNewVersionMessage()}
${this.renderImpersonationMessage()}
${map(sidebarContent, renderOneSidebarItem)}
${this.renderEnterpriseMessage()}
`;
}
renderNewVersionMessage() {
return this.version && this.version.versionCurrent !== VERSION
? html`
<ak-sidebar-item ?highlight=${true}>
<span slot="label"
>${msg("A newer version of the frontend is available.")}</span
>
</ak-sidebar-item>
`
: nothing;
}
renderImpersonationMessage() {
return this.user?.original
? html`<ak-sidebar-item
?highlight=${true}
@click=${() => {
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
window.location.reload();
});
}}
>
<span slot="label"
>${msg(
str`You're currently impersonating ${this.user.user.username}. Click to stop.`,
)}</span
>
</ak-sidebar-item>`
: nothing;
}
renderEnterpriseMessage() {
return this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise)
? html`
<ak-sidebar-item>
<span slot="label">${msg("Enterprise")}</span>
<ak-sidebar-item path="/enterprise/licenses">
<span slot="label">${msg("Licenses")}</span>
</ak-sidebar-item>
</ak-sidebar-item>
`
: nothing;
}
}

View File

@ -52,9 +52,9 @@ export const ROUTES: Route[] = [
await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`;
}),
new Route(new RegExp("^/core/tenants$"), async () => {
await import("@goauthentik/admin/tenants/TenantListPage");
return html`<ak-tenant-list></ak-tenant-list>`;
new Route(new RegExp("^/core/brands"), async () => {
await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-brand-list></ak-brand-list>`;
}),
new Route(new RegExp("^/policy/policies$"), async () => {
await import("@goauthentik/admin/policies/PolicyListPage");

View File

@ -57,7 +57,7 @@ export class RecentEventsCard extends Table<Event> {
new TableColumn(msg("User"), "user"),
new TableColumn(msg("Creation Date"), "created"),
new TableColumn(msg("Client IP"), "client_ip"),
new TableColumn(msg("Tenant"), "tenant_name"),
new TableColumn(msg("Brand"), "brand_name"),
];
}
@ -88,7 +88,7 @@ export class RecentEventsCard extends Table<Event> {
html`<span>${item.created?.toLocaleString()}</span>`,
html` <div>${item.clientIp || msg("-")}</div>
<small>${EventGeo(item)}</small>`,
html`<span>${item.tenant?.name || msg("-")}</span>`,
html`<span>${item.brand?.name || msg("-")}</span>`,
];
}

View File

@ -1,6 +1,6 @@
import "@goauthentik/admin/common/ak-core-group-search";
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-radio-input";
@ -49,12 +49,12 @@ export class ApplicationWizardApplicationDetails extends BaseProviderPanel {
?required=${true}
name="authorizationFlow"
>
<ak-tenanted-flow-search
<ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication}
.brandFlow=${rootInterface()?.brand?.flowAuthentication}
required
></ak-tenanted-flow-search>
></ak-branded-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal>

View File

@ -1,5 +1,5 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import "@goauthentik/admin/common/ak-flow-search/ak-branted-flow-search";
import {
clientTypeOptions,
issuerModeOptions,

View File

@ -1,5 +1,5 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search";
import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-text-input";
import { rootInterface } from "@goauthentik/elements/Base";
@ -34,12 +34,12 @@ export class ApplicationWizardAuthenticationByRadius extends BaseProviderPanel {
?required=${true}
name="authorizationFlow"
>
<ak-tenanted-flow-search
<ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication}
.brandFlow=${rootInterface()?.brand?.flowAuthentication}
required
></ak-tenanted-flow-search>
></ak-branded-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal>

View File

@ -1,6 +1,6 @@
import "@goauthentik/admin/common/ak-core-group-search";
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-radio-input";

View File

@ -1,5 +1,5 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-switch-input";

View File

@ -8,40 +8,40 @@ import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect";
import { DefaultTenant } from "@goauthentik/elements/sidebar/SidebarBrand";
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
import YAML from "yaml";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { CoreApi, FlowsInstancesListDesignationEnum, Tenant } from "@goauthentik/api";
import { Brand, CoreApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
@customElement("ak-tenant-form")
export class TenantForm extends ModelForm<Tenant, string> {
loadInstance(pk: string): Promise<Tenant> {
return new CoreApi(DEFAULT_CONFIG).coreTenantsRetrieve({
tenantUuid: pk,
@customElement("ak-brand-form")
export class BrandForm extends ModelForm<Brand, string> {
loadInstance(pk: string): Promise<Brand> {
return new CoreApi(DEFAULT_CONFIG).coreBrandsRetrieve({
brandUuid: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return msg("Successfully updated tenant.");
return msg("Successfully updated brand.");
} else {
return msg("Successfully created tenant.");
return msg("Successfully created brand.");
}
}
async send(data: Tenant): Promise<Tenant> {
if (this.instance?.tenantUuid) {
return new CoreApi(DEFAULT_CONFIG).coreTenantsUpdate({
tenantUuid: this.instance.tenantUuid,
tenantRequest: data,
async send(data: Brand): Promise<Brand> {
if (this.instance?.brandUuid) {
return new CoreApi(DEFAULT_CONFIG).coreBrandsUpdate({
brandUuid: this.instance.brandUuid,
brandRequest: data,
});
} else {
return new CoreApi(DEFAULT_CONFIG).coreTenantsCreate({
tenantRequest: data,
return new CoreApi(DEFAULT_CONFIG).coreBrandsCreate({
brandRequest: data,
});
}
}
@ -79,7 +79,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
<span class="pf-c-switch__label">${msg("Default")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg("Use this tenant for each domain that doesn't have a dedicated tenant.")}
${msg("Use this brand for each domain that doesn't have a dedicated brand.")}
</p>
</ak-form-element-horizontal>
@ -95,7 +95,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
type="text"
value="${first(
this.instance?.brandingTitle,
DefaultTenant.brandingTitle,
DefaultBrand.brandingTitle,
)}"
class="pf-c-form-control"
required
@ -111,10 +111,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
>
<input
type="text"
value="${first(
this.instance?.brandingLogo,
DefaultTenant.brandingLogo,
)}"
value="${first(this.instance?.brandingLogo, DefaultBrand.brandingLogo)}"
class="pf-c-form-control"
required
/>
@ -131,7 +128,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
type="text"
value="${first(
this.instance?.brandingFavicon,
DefaultTenant.brandingFavicon,
DefaultBrand.brandingFavicon,
)}"
class="pf-c-form-control"
required
@ -274,7 +271,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
</ak-codemirror>
<p class="pf-c-form__helper-text">
${msg(
"Set custom attributes using YAML or JSON. Any attributes set here will be inherited by users, if the request is handled by this tenant.",
"Set custom attributes using YAML or JSON. Any attributes set here will be inherited by users, if the request is handled by this brand.",
)}
</p>
</ak-form-element-horizontal>

View File

@ -1,4 +1,4 @@
import "@goauthentik/admin/tenants/TenantForm";
import "@goauthentik/admin/brands/BrandForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/components/ak-status-label";
@ -16,21 +16,21 @@ import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { CoreApi, RbacPermissionsAssignedByUsersListModelEnum, Tenant } from "@goauthentik/api";
import { Brand, CoreApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
@customElement("ak-tenant-list")
export class TenantListPage extends TablePage<Tenant> {
@customElement("ak-brand-list")
export class BrandListPage extends TablePage<Brand> {
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return msg("Tenants");
return msg("Brands");
}
pageDescription(): string {
return msg("Configure visual settings and defaults for different domains.");
}
pageIcon(): string {
return "pf-icon pf-icon-tenant";
return "pf-icon pf-icon-brand";
}
checkbox = true;
@ -38,8 +38,8 @@ export class TenantListPage extends TablePage<Tenant> {
@property()
order = "domain";
async apiEndpoint(page: number): Promise<PaginatedResponse<Tenant>> {
return new CoreApi(DEFAULT_CONFIG).coreTenantsList({
async apiEndpoint(page: number): Promise<PaginatedResponse<Brand>> {
return new CoreApi(DEFAULT_CONFIG).coreBrandsList({
ordering: this.order,
page: page,
pageSize: (await uiConfig()).pagination.perPage,
@ -58,19 +58,19 @@ export class TenantListPage extends TablePage<Tenant> {
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Tenant(s)")}
objectLabel=${msg("Brand(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Tenant) => {
.metadata=${(item: Brand) => {
return [{ key: msg("Domain"), value: item.domain }];
}}
.usedBy=${(item: Tenant) => {
return new CoreApi(DEFAULT_CONFIG).coreTenantsUsedByList({
tenantUuid: item.tenantUuid,
.usedBy=${(item: Brand) => {
return new CoreApi(DEFAULT_CONFIG).coreBrandsUsedByList({
brandUuid: item.brandUuid,
});
}}
.delete=${(item: Tenant) => {
return new CoreApi(DEFAULT_CONFIG).coreTenantsDestroy({
tenantUuid: item.tenantUuid,
.delete=${(item: Brand) => {
return new CoreApi(DEFAULT_CONFIG).coreBrandsDestroy({
brandUuid: item.brandUuid,
});
}}
>
@ -80,14 +80,14 @@ export class TenantListPage extends TablePage<Tenant> {
</ak-forms-delete-bulk>`;
}
row(item: Tenant): TemplateResult[] {
row(item: Brand): TemplateResult[] {
return [
html`${item.domain}`,
html`<ak-status-label ?good=${item._default}></ak-status-label>`,
html`<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update Tenant")} </span>
<ak-tenant-form slot="form" .instancePk=${item.tenantUuid}> </ak-tenant-form>
<span slot="header"> ${msg("Update Brand")} </span>
<ak-brand-form slot="form" .instancePk=${item.brandUuid}> </ak-brand-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit"></i>
@ -96,8 +96,8 @@ export class TenantListPage extends TablePage<Tenant> {
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.TenantsTenant}
objectPk=${item.tenantUuid}
model=${RbacPermissionsAssignedByUsersListModelEnum.BrandsBrand}
objectPk=${item.brandUuid}
>
</ak-rbac-object-permission-modal>`,
];
@ -107,8 +107,8 @@ export class TenantListPage extends TablePage<Tenant> {
return html`
<ak-forms-modal>
<span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Tenant")} </span>
<ak-tenant-form slot="form"> </ak-tenant-form>
<span slot="header"> ${msg("Create Brand")} </span>
<ak-brand-form slot="form"> </ak-brand-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>
`;

View File

@ -28,7 +28,7 @@ export function getFlowValue(flow: Flow | undefined): string | undefined {
*
* A wrapper around SearchSelect that understands the basic semantics of querying about Flows. This
* code eliminates the long blocks of unreadable invocation that were embedded in every provider, as well as in
* sources, tenants, and applications.
* sources, brands, and applications.
*
*/
@ -94,7 +94,7 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
}
/* This is the most commonly overridden method of this class. About half of the Flow Searches
* use this method, but several have more complex needs, such as relating to the tenant, or just
* use this method, but several have more complex needs, such as relating to the brand, or just
* returning false.
*/

View File

@ -0,0 +1,34 @@
import { customElement, property } from "lit/decorators.js";
import type { Flow } from "@goauthentik/api";
import FlowSearch from "./FlowSearch";
/**
* Search for flows that may have a fallback specified by the brand settings
*
* @element ak-branded-flow-search
*
*/
@customElement("ak-branded-flow-search")
export class AkBrandedFlowSearch<T extends Flow> extends FlowSearch<T> {
/**
* The Associated ID of the flow the brand has, to compare if possible
*
* @attr
*/
@property({ attribute: false, type: String })
brandFlow?: string;
constructor() {
super();
this.selected = this.selected.bind(this);
}
selected(flow: Flow): boolean {
return super.selected(flow) || flow.pk === this.brandFlow;
}
}
export default AkBrandedFlowSearch;

View File

@ -1,34 +0,0 @@
import { customElement, property } from "lit/decorators.js";
import type { Flow } from "@goauthentik/api";
import FlowSearch from "./FlowSearch";
/**
* Search for flows that may have a fallback specified by the tenant settings
*
* @element ak-tenanted-flow-search
*
*/
@customElement("ak-tenanted-flow-search")
export class AkTenantedFlowSearch<T extends Flow> extends FlowSearch<T> {
/**
* The Associated ID of the flow the tenant has, to compare if possible
*
* @attr
*/
@property({ attribute: false, type: String })
tenantFlow?: string;
constructor() {
super();
this.selected = this.selected.bind(this);
}
selected(flow: Flow): boolean {
return super.selected(flow) || flow.pk === this.tenantFlow;
}
}
export default AkTenantedFlowSearch;

View File

@ -50,7 +50,7 @@ export class EventListPage extends TablePage<Event> {
new TableColumn(msg("User"), "user"),
new TableColumn(msg("Creation Date"), "created"),
new TableColumn(msg("Client IP"), "client_ip"),
new TableColumn(msg("Tenant"), "tenant_name"),
new TableColumn(msg("Brand"), "brand_name"),
new TableColumn(msg("Actions")),
];
}
@ -75,7 +75,7 @@ export class EventListPage extends TablePage<Event> {
html`<div>${item.clientIp || msg("-")}</div>
<small>${EventGeo(item)}</small>`,
html`<span>${item.tenant?.name || msg("-")}</span>`,
html`<span>${item.brand?.name || msg("-")}</span>`,
html`<a href="#/events/log/${item.pk}">
<pf-tooltip position="top" content=${msg("Show details")}>
<i class="fas fa-share-square"></i>

View File

@ -139,12 +139,12 @@ export class EventViewPage extends AKElement {
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Tenant")}</span
>${msg("Brand")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.event.tenant?.name || msg("-")}
${this.event.brand?.name || msg("-")}
</div>
</dd>
</div>

View File

@ -293,7 +293,7 @@ export class RelatedUserList extends Table<User> {
${msg("Set password")}
</button>
</ak-forms-modal>
${rootInterface()?.tenant?.flowRecovery
${rootInterface()?.brand?.flowRecovery
? html`
<ak-action-button
class="pf-m-secondary"
@ -355,7 +355,7 @@ export class RelatedUserList extends Table<User> {
`
: html` <p>
${msg(
"To let a user directly reset a their password, configure a recovery flow on the currently active tenant.",
"To let a user directly reset a their password, configure a recovery flow on the currently active brand.",
)}
</p>`}
</div>

View File

@ -1,5 +1,5 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import { rootInterface } from "@goauthentik/elements/Base";
@ -56,7 +56,7 @@ export class LDAPProviderFormPage extends ModelForm<LDAPProvider, number> {
// All Provider objects have an Authorization flow, but not all providers have an Authentication
// flow. LDAP needs only one field, but it is not an Authorization field, it is an
// Authentication field. So, yeah, we're using the authorization field to store the
// authentication information, which is why the ak-tenanted-flow-search call down there looks so
// authentication information, which is why the ak-branded-flow-search call down there looks so
// weird-- we're looking up Authentication flows, but we're storing them in the Authorization
// field of the target Provider.
renderForm(): TemplateResult {
@ -73,12 +73,12 @@ export class LDAPProviderFormPage extends ModelForm<LDAPProvider, number> {
?required=${true}
name="authorizationFlow"
>
<ak-tenanted-flow-search
<ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${this.instance?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication}
.brandFlow=${rootInterface()?.brand?.flowAuthentication}
required
></ak-tenanted-flow-search>
></ak-branded-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Search group")} name="searchGroup">

View File

@ -45,7 +45,7 @@ export class RadiusProviderFormPage extends ModelForm<RadiusProvider, number> {
// All Provider objects have an Authorization flow, but not all providers have an Authentication
// flow. Radius needs only one field, but it is not the Authorization field, it is an
// Authentication field. So, yeah, we're using the authorization field to store the
// authentication information, which is why the ak-tenanted-flow-search call down there looks so
// authentication information, which is why the ak-branded-flow-search call down there looks so
// weird-- we're looking up Authentication flows, but we're storing them in the Authorization
// field of the target Provider.
renderForm(): TemplateResult {
@ -62,12 +62,12 @@ export class RadiusProviderFormPage extends ModelForm<RadiusProvider, number> {
?required=${true}
name="authorizationFlow"
>
<ak-tenanted-flow-search
<ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${this.instance?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication}
.brandFlow=${rootInterface()?.brand?.flowAuthentication}
required
></ak-tenanted-flow-search>
></ak-branded-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="mfaSupport">

View File

@ -28,7 +28,7 @@ class PreviewStageHost implements StageHost {
challenge = undefined;
flowSlug = undefined;
loading = false;
tenant = undefined;
brand = undefined;
async submit(payload: unknown): Promise<boolean> {
this.promptForm.previewResult = payload;
return false;

View File

@ -63,7 +63,7 @@ export const requestRecoveryLink = (user: User) =>
showMessage({
level: MessageLevel.error,
message: msg(
"The current tenant must have a recovery flow configured to use a recovery link",
"The current brand must have a recovery flow configured to use a recovery link",
),
}),
),
@ -355,7 +355,7 @@ export class UserListPage extends TablePage<User> {
${msg("Set password")}
</button>
</ak-forms-modal>
${rootInterface()?.tenant?.flowRecovery
${rootInterface()?.brand?.flowRecovery
? html`
<ak-action-button
class="pf-m-secondary"
@ -373,7 +373,7 @@ export class UserListPage extends TablePage<User> {
`
: html` <p>
${msg(
"To let a user directly reset a their password, configure a recovery flow on the currently active tenant.",
"To let a user directly reset a their password, configure a recovery flow on the currently active brand.",
)}
</p>`}
</div>

View File

@ -6,7 +6,7 @@ import {
import { EVENT_LOCALE_REQUEST, EVENT_REFRESH, VERSION } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { Config, Configuration, CoreApi, CurrentTenant, RootApi } from "@goauthentik/api";
import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api";
let globalConfigPromise: Promise<Config> | undefined = Promise.resolve(globalAK().config);
export function config(): Promise<Config> {
@ -16,7 +16,7 @@ export function config(): Promise<Config> {
return globalConfigPromise;
}
export function tenantSetFavicon(tenant: CurrentTenant) {
export function brandSetFavicon(brand: CurrentBrand) {
/**
* <link rel="icon" href="/static/dist/assets/icons/icon.png">
* <link rel="shortcut icon" href="/static/dist/assets/icons/icon.png">
@ -29,36 +29,36 @@ export function tenantSetFavicon(tenant: CurrentTenant) {
relIcon.rel = rel;
document.getElementsByTagName("head")[0].appendChild(relIcon);
}
relIcon.href = tenant.brandingFavicon;
relIcon.href = brand.brandingFavicon;
});
}
export function tenantSetLocale(tenant: CurrentTenant) {
if (tenant.defaultLocale === "") {
export function brandSetLocale(brand: CurrentBrand) {
if (brand.defaultLocale === "") {
return;
}
console.debug("authentik/locale: setting locale from tenant default");
console.debug("authentik/locale: setting locale from brand default");
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale: tenant.defaultLocale },
detail: { locale: brand.defaultLocale },
}),
);
}
let globalTenantPromise: Promise<CurrentTenant> | undefined = Promise.resolve(globalAK().tenant);
export function tenant(): Promise<CurrentTenant> {
if (!globalTenantPromise) {
globalTenantPromise = new CoreApi(DEFAULT_CONFIG)
.coreTenantsCurrentRetrieve()
.then((tenant) => {
tenantSetFavicon(tenant);
tenantSetLocale(tenant);
return tenant;
let globalBrandPromise: Promise<CurrentBrand> | undefined = Promise.resolve(globalAK().brand);
export function brand(): Promise<CurrentBrand> {
if (!globalBrandPromise) {
globalBrandPromise = new CoreApi(DEFAULT_CONFIG)
.coreBrandsCurrentRetrieve()
.then((brand) => {
brandSetFavicon(brand);
brandSetLocale(brand);
return brand;
});
}
return globalTenantPromise;
return globalBrandPromise;
}
export function getMetaContent(key: string): string {
@ -75,7 +75,7 @@ export const DEFAULT_CONFIG = new Configuration({
middleware: [
new CSRFMiddleware(),
new EventMiddleware(),
new LoggingMiddleware(globalAK().tenant),
new LoggingMiddleware(globalAK().brand),
],
});
@ -90,9 +90,9 @@ window.addEventListener(EVENT_REFRESH, () => {
// Upon global refresh, disregard whatever was pre-hydrated and
// actually load info from API
globalConfigPromise = undefined;
globalTenantPromise = undefined;
globalBrandPromise = undefined;
config();
tenant();
brand();
});
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);

View File

@ -2,7 +2,7 @@ import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
import { getCookie } from "@goauthentik/common/utils";
import {
CurrentTenant,
CurrentBrand,
FetchParams,
Middleware,
RequestContext,
@ -18,13 +18,13 @@ export interface RequestInfo {
}
export class LoggingMiddleware implements Middleware {
tenant: CurrentTenant;
constructor(tenant: CurrentTenant) {
this.tenant = tenant;
brand: CurrentBrand;
constructor(brand: CurrentBrand) {
this.brand = brand;
}
post(context: ResponseContext): Promise<Response | void> {
let msg = `authentik/api[${this.tenant.matchedDomain}]: `;
let msg = `authentik/api[${this.brand.matchedDomain}]: `;
// https://developer.mozilla.org/en-US/docs/Web/API/console#styling_console_output
msg += `%c${context.response.status}%c ${context.init.method} ${context.url}`;
let style = "";

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