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

View File

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

View File

@ -62,7 +62,7 @@ class ConfigView(APIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
def get_capabilities(self) -> list[Capabilities]: def get_capabilities(self, request: Request) -> list[Capabilities]:
"""Get all capabilities this server instance supports""" """Get all capabilities this server instance supports"""
caps = [] caps = []
deb_test = settings.DEBUG or settings.TEST deb_test = settings.DEBUG or settings.TEST
@ -70,7 +70,7 @@ class ConfigView(APIView):
caps.append(Capabilities.CAN_SAVE_MEDIA) caps.append(Capabilities.CAN_SAVE_MEDIA)
if GEOIP_READER.enabled: if GEOIP_READER.enabled:
caps.append(Capabilities.CAN_GEO_IP) caps.append(Capabilities.CAN_GEO_IP)
if CONFIG.get_bool("impersonation"): if request.tenant.impersonation:
caps.append(Capabilities.CAN_IMPERSONATE) caps.append(Capabilities.CAN_IMPERSONATE)
if settings.DEBUG: # pragma: no cover if settings.DEBUG: # pragma: no cover
caps.append(Capabilities.CAN_DEBUG) caps.append(Capabilities.CAN_DEBUG)
@ -81,7 +81,7 @@ class ConfigView(APIView):
caps.append(result) caps.append(result)
return caps return caps
def get_config(self) -> ConfigSerializer: def get_config(self, request: Request) -> ConfigSerializer:
"""Get Config""" """Get Config"""
return ConfigSerializer( return ConfigSerializer(
{ {
@ -92,7 +92,7 @@ class ConfigView(APIView):
"send_pii": CONFIG.get("error_reporting.send_pii"), "send_pii": CONFIG.get("error_reporting.send_pii"),
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)), "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": CONFIG.get_int("cache.timeout"),
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"), "cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"), "cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
@ -103,4 +103,4 @@ class ConfigView(APIView):
@extend_schema(responses={200: ConfigSerializer(many=False)}) @extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Retrieve public configuration options""" """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.models import BlueprintInstance
from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.tests import apply_blueprint
from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.importer import Importer
from authentik.tenants.models import Tenant from authentik.brands.models import Brand
class TestPackaged(TransactionTestCase): class TestPackaged(TransactionTestCase):
"""Empty class, test methods are added dynamically""" """Empty class, test methods are added dynamically"""
@apply_blueprint("default/default-tenant.yaml") @apply_blueprint("default/default-brand.yaml")
def test_decorator_static(self): def test_decorator_static(self):
"""Test @apply_blueprint decorator""" """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: 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): class Migration(migrations.Migration):
replaces = [ replaces = [
("authentik_tenants", "0001_initial"), ("authentik_brands", "0001_initial"),
("authentik_tenants", "0002_default"), ("authentik_brands", "0002_default"),
("authentik_tenants", "0003_tenant_branding_favicon"), ("authentik_brands", "0003_tenant_branding_favicon"),
("authentik_tenants", "0004_tenant_event_retention"), ("authentik_brands", "0004_tenant_event_retention"),
("authentik_tenants", "0005_tenant_web_certificate"), ("authentik_brands", "0005_tenant_web_certificate"),
] ]
initial = True initial = True
@ -25,10 +25,10 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="Tenant", name="Brand",
fields=[ fields=[
( (
"tenant_uuid", "brand_uuid",
models.UUIDField( models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False default=uuid.uuid4, editable=False, primary_key=True, serialize=False
), ),
@ -37,7 +37,7 @@ class Migration(migrations.Migration):
"domain", "domain",
models.TextField( models.TextField(
help_text=( 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`" " `aa.b` and `ba.b`"
) )
), ),
@ -53,7 +53,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_authentication", related_name="brand_authentication",
to="authentik_flows.flow", to="authentik_flows.flow",
), ),
), ),
@ -62,7 +62,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_invalidation", related_name="brand_invalidation",
to="authentik_flows.flow", to="authentik_flows.flow",
), ),
), ),
@ -71,7 +71,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_recovery", related_name="brand_recovery",
to="authentik_flows.flow", to="authentik_flows.flow",
), ),
), ),
@ -80,23 +80,23 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_unenrollment", related_name="brand_unenrollment",
to="authentik_flows.flow", to="authentik_flows.flow",
), ),
), ),
], ],
options={ options={
"verbose_name": "Tenant", "verbose_name": "Brand",
"verbose_name_plural": "Tenants", "verbose_name_plural": "Brands",
}, },
), ),
migrations.AddField( migrations.AddField(
model_name="tenant", model_name="brand",
name="branding_favicon", name="branding_favicon",
field=models.TextField(default="/static/dist/assets/icons/icon.png"), field=models.TextField(default="/static/dist/assets/icons/icon.png"),
), ),
migrations.AddField( migrations.AddField(
model_name="tenant", model_name="brand",
name="event_retention", name="event_retention",
field=models.TextField( field=models.TextField(
default="days=365", default="days=365",
@ -108,7 +108,7 @@ class Migration(migrations.Migration):
), ),
), ),
migrations.AddField( migrations.AddField(
model_name="tenant", model_name="brand",
name="web_certificate", name="web_certificate",
field=models.ForeignKey( field=models.ForeignKey(
default=None, default=None,

View File

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

View File

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

View File

@ -7,17 +7,17 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_flows", "0023_flow_denied_action"), ("authentik_flows", "0023_flow_denied_action"),
("authentik_tenants", "0003_tenant_attributes"), ("authentik_brands", "0003_tenant_attributes"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name="tenant", model_name="brand",
name="flow_device_code", name="flow_device_code",
field=models.ForeignKey( field=models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_device_code", related_name="brand_device_code",
to="authentik_flows.flow", 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.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase 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.events.models import Event, EventAction
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.tenants.api import Themes
from authentik.tenants.models import Tenant
class TestTenants(APITestCase): class TestBrands(APITestCase):
"""Test tenants""" """Test brands"""
def test_current_tenant(self): def test_current_brand(self):
"""Test Current tenant API""" """Test Current brand API"""
tenant = create_test_tenant() brand = create_test_brand()
self.assertJSONEqual( 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_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png", "branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik", "branding_title": "authentik",
"matched_domain": tenant.domain, "matched_domain": brand.domain,
"ui_footer_links": CONFIG.get("footer_links"), "ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC, "ui_theme": Themes.AUTOMATIC,
"default_locale": "", "default_locale": "",
}, },
) )
def test_tenant_subdomain(self): def test_brand_subdomain(self):
"""Test Current tenant API""" """Test Current brand API"""
Tenant.objects.all().delete() Brand.objects.all().delete()
Tenant.objects.create(domain="bar.baz", branding_title="custom") Brand.objects.create(domain="bar.baz", branding_title="custom")
self.assertJSONEqual( self.assertJSONEqual(
self.client.get( 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(), ).content.decode(),
{ {
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg", "branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png", "branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom", "branding_title": "custom",
"matched_domain": "bar.baz", "matched_domain": "bar.baz",
"ui_footer_links": CONFIG.get("footer_links"), "ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC, "ui_theme": Themes.AUTOMATIC,
"default_locale": "", "default_locale": "",
}, },
) )
def test_fallback(self): def test_fallback(self):
"""Test fallback tenant""" """Test fallback brand"""
Tenant.objects.all().delete() Brand.objects.all().delete()
self.assertJSONEqual( 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_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png", "branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik", "branding_title": "authentik",
"matched_domain": "fallback", "matched_domain": "fallback",
"ui_footer_links": CONFIG.get("footer_links"), "ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC, "ui_theme": Themes.AUTOMATIC,
"default_locale": "", "default_locale": "",
}, },
) )
def test_event_retention(self): def test_event_retention(self):
"""Test tenant's event retention""" """Test brand's event retention"""
tenant = Tenant.objects.create( brand = Brand.objects.create(
domain="foo", domain="foo",
default=True, default=True,
branding_title="custom", branding_title="custom",
@ -75,7 +75,7 @@ class TestTenants(APITestCase):
) )
factory = RequestFactory() factory = RequestFactory()
request = factory.get("/") request = factory.get("/")
request.tenant = tenant request.brand = brand
event = Event.new(action=EventAction.SYSTEM_EXCEPTION, message="test").from_http(request) 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(event.expires.day, (event.created + timedelta_from_string("weeks=3")).day)
self.assertEqual( self.assertEqual(
@ -87,8 +87,8 @@ class TestTenants(APITestCase):
) )
def test_create_default_multiple(self): def test_create_default_multiple(self):
"""Test attempted creation of multiple default tenants""" """Test attempted creation of multiple default brands"""
Tenant.objects.create( Brand.objects.create(
domain="foo", domain="foo",
default=True, default=True,
branding_title="custom", branding_title="custom",
@ -97,6 +97,6 @@ class TestTenants(APITestCase):
user = create_test_admin_user() user = create_test_admin_user()
self.client.force_login(user) self.client.force_login(user)
response = self.client.post( 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) 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.admin.api.metrics import CoordinateSerializer
from authentik.api.decorators import permission_required from authentik.api.decorators import permission_required
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT 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.used_by import UsedByMixin
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
from authentik.core.middleware import ( 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.models import EmailStage
from authentik.stages.email.tasks import send_mails from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
@ -227,7 +227,7 @@ class UserSelfSerializer(ModelSerializer):
} }
def get_settings(self, user: User) -> dict[str, Any]: 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", {}) return user.group_attributes(self._context["request"]).get("settings", {})
def get_system_permissions(self, user: User) -> list[str]: 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) return User.objects.all().exclude(pk=get_anonymous_user().pk)
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]: 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""" 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 # Check that there is a recovery flow, if not return an error
flow = tenant.flow_recovery flow = brand.flow_recovery
if not flow: if not flow:
LOGGER.debug("No recovery flow set") LOGGER.debug("No recovery flow set")
return None, None return None, None
@ -624,7 +624,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
@action(detail=True, methods=["POST"]) @action(detail=True, methods=["POST"])
def impersonate(self, request: Request, pk: int) -> Response: def impersonate(self, request: Request, pk: int) -> Response:
"""Impersonate a user""" """Impersonate a user"""
if not CONFIG.get_bool("impersonation"): if not request.tenant.impersonation:
LOGGER.debug("User attempted to impersonate", user=request.user) LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401) return Response(status=401)
if not request.user.has_perm("impersonate"): 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, """Get a dictionary containing the attributes from all groups the user belongs to,
including the users attributes""" including the users attributes"""
final_attributes = {} final_attributes = {}
if request and hasattr(request, "tenant"): if request and hasattr(request, "brand"):
always_merger.merge(final_attributes, request.tenant.attributes) always_merger.merge(final_attributes, request.brand.attributes)
for group in self.all_groups().order_by("name"): for group in self.all_groups().order_by("name"):
always_merger.merge(final_attributes, group.attributes) always_merger.merge(final_attributes, group.attributes)
always_merger.merge(final_attributes, self.attributes) always_merger.merge(final_attributes, self.attributes)
@ -262,7 +262,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
except Exception as exc: except Exception as exc:
LOGGER.warning("Failed to get default locale", exc=exc) LOGGER.warning("Failed to get default locale", exc=exc)
if request: if request:
return request.tenant.locale return request.brand.locale
return "" return ""
@property @property

View File

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

View File

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

View File

@ -4,7 +4,7 @@
{% load i18n %} {% load i18n %}
{% block title %} {% block title %}
{% trans 'End session' %} - {{ tenant.branding_title }} {% trans 'End session' %} - {{ brand.branding_title }}
{% endblock %} {% endblock %}
{% block card_title %} {% block card_title %}
@ -16,7 +16,7 @@ You've logged out of {{ application }}.
{% block card %} {% block card %}
<form method="POST" class="pf-c-form"> <form method="POST" class="pf-c-form">
<p> <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. 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 %} {% endblocktrans %}
</p> </p>
@ -26,7 +26,7 @@ You've logged out of {{ application }}.
</a> </a>
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary"> <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 }} Log out of {{ branding_title }}
{% endblocktrans %} {% endblocktrans %}
</a> </a>

View File

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

View File

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

View File

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

View File

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

View File

@ -3,12 +3,12 @@ from typing import Optional
from django.utils.text import slugify from django.utils.text import slugify
from authentik.brands.models import Brand
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.crypto.builder import CertificateBuilder from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.tenants.models import Tenant
def create_test_flow( def create_test_flow(
@ -43,12 +43,12 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
return user return user
def create_test_tenant(**kwargs) -> Tenant: def create_test_brand(**kwargs) -> Brand:
"""Generate a test tenant, removing all other tenants to make sure this one """Generate a test brand, removing all other brands to make sure this one
matches.""" matches."""
uid = generate_id(20) uid = generate_id(20)
Tenant.objects.all().delete() Brand.objects.all().delete()
return Tenant.objects.create(domain=uid, default=True, **kwargs) return Brand.objects.create(domain=uid, default=True, **kwargs)
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair: 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 import get_build_hash
from authentik.admin.tasks import LOCAL_VERSION from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.tenants.api import CurrentTenantSerializer
class InterfaceView(TemplateView): class InterfaceView(TemplateView):
@ -18,7 +18,7 @@ class InterfaceView(TemplateView):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data) 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_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
kwargs["build"] = get_build_hash() kwargs["build"] = get_build_hash()

View File

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

View File

@ -305,7 +305,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="event", model_name="event",
name="tenant", 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( migrations.AlterField(
model_name="event", 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 structlog.stdlib import get_logger
from authentik import get_full_version from authentik import get_full_version
from authentik.brands.models import Brand
from authentik.brands.utils import DEFAULT_BRAND
from authentik.core.middleware import ( from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER, SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_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.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
from authentik.tenants.utils import DEFAULT_TENANT
LOGGER = get_logger() LOGGER = get_logger()
if TYPE_CHECKING: if TYPE_CHECKING:
@ -50,13 +50,13 @@ if TYPE_CHECKING:
def default_event_duration(): def default_event_duration():
"""Default duration an Event is saved. """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) return now() + timedelta(days=365)
def default_tenant(): def default_brand():
"""Get a default value for tenant""" """Get a default value for brand"""
return sanitize_dict(model_to_dict(DEFAULT_TENANT)) return sanitize_dict(model_to_dict(DEFAULT_BRAND))
class NotificationTransportError(SentryIgnoredException): class NotificationTransportError(SentryIgnoredException):
@ -170,7 +170,7 @@ class Event(SerializerModel, ExpiringModel):
context = models.JSONField(default=dict, blank=True) context = models.JSONField(default=dict, blank=True)
client_ip = models.GenericIPAddressField(null=True) client_ip = models.GenericIPAddressField(null=True)
created = models.DateTimeField(auto_now_add=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 # Shadow the expires attribute from ExpiringModel to override the default duration
expires = models.DateTimeField(default=default_event_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"]: if QS_QUERY in self.context["http_request"]["args"]:
wrapped = self.context["http_request"]["args"][QS_QUERY] wrapped = self.context["http_request"]["args"][QS_QUERY]
self.context["http_request"]["args"] = cleanse_dict(QueryDict(wrapped)) self.context["http_request"]["args"] = cleanse_dict(QueryDict(wrapped))
if hasattr(request, "tenant"): if hasattr(request, "brand"):
tenant: Tenant = request.tenant brand: Brand = request.brand
# Because self.created only gets set on save, we can't use it's value here # 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 # hence we set self.created to now and then use it
self.created = now() self.created = now()
self.expires = self.created + timedelta_from_string(tenant.event_retention) self.expires = self.created + timedelta_from_string(brand.event_retention)
self.tenant = sanitize_dict(model_to_dict(tenant)) self.brand = sanitize_dict(model_to_dict(brand))
if hasattr(request, "user"): if hasattr(request, "user"):
original_user = None original_user = None
if hasattr(request, "session"): 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.invitation.signals import invitation_used
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
from authentik.stages.user_write.signals import user_write from authentik.stages.user_write.signals import user_write
from authentik.tenants.utils import get_current_tenant
SESSION_LOGIN_EVENT = "login_event" SESSION_LOGIN_EVENT = "login_event"
@ -93,5 +94,5 @@ def event_post_save_notification(sender, instance: Event, **_):
@receiver(pre_delete, sender=User) @receiver(pre_delete, sender=User)
def event_user_pre_delete_cleanup(sender, instance: User, **_): def event_user_pre_delete_cleanup(sender, instance: User, **_):
"""If gdpr_compliance is enabled, remove all the user's events""" """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) gdpr_cleanup.delay(instance.pk)

View File

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

View File

@ -22,6 +22,7 @@ from sentry_sdk.api import set_tag
from sentry_sdk.hub import Hub from sentry_sdk.hub import Hub
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.brands.models import Brand
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import Event, EventAction, cleanse_dict from authentik.events.models import Event, EventAction, cleanse_dict
from authentik.flows.apps import HIST_FLOW_EXECUTION_STAGE_TIME 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.reflection import all_subclasses, class_to_path
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
# Argument used to redirect user after login # Argument used to redirect user after login
@ -490,11 +490,11 @@ class ToDefaultFlow(View):
def get_flow(self) -> Flow: def get_flow(self) -> Flow:
"""Get a flow for the selected designation""" """Get a flow for the selected designation"""
tenant: Tenant = self.request.tenant brand: Brand = self.request.brand
flow = None flow = None
# First, attempt to get default flow from tenant # First, attempt to get default flow from brand
if self.designation == FlowDesignation.AUTHENTICATION: if self.designation == FlowDesignation.AUTHENTICATION:
flow = tenant.flow_authentication flow = brand.flow_authentication
# Check if we have a default flow from application # Check if we have a default flow from application
application: Optional[Application] = self.request.session.get( application: Optional[Application] = self.request.session.get(
SESSION_KEY_APPLICATION_PRE SESSION_KEY_APPLICATION_PRE
@ -502,7 +502,7 @@ class ToDefaultFlow(View):
if application and application.provider and application.provider.authentication_flow: if application and application.provider and application.provider.authentication_flow:
flow = application.provider.authentication_flow flow = application.provider.authentication_flow
elif self.designation == FlowDesignation.INVALIDATION: elif self.designation == FlowDesignation.INVALIDATION:
flow = tenant.flow_invalidation flow = brand.flow_invalidation
if flow: if flow:
return flow return flow
# If no flow was set, get the first based on slug and policy # 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.config import CONFIG, get_path_from_dict
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
from authentik.tenants.utils import get_current_tenant
GRAVATAR_URL = "https://secure.gravatar.com" GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png") DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
@ -183,7 +184,7 @@ def get_avatar(user: "User") -> str:
"initials": avatar_mode_generated, "initials": avatar_mode_generated,
"gravatar": avatar_mode_gravatar, "gravatar": avatar_mode_gravatar,
} }
modes: str = CONFIG.get("avatars", "none") modes: str = get_current_tenant().avatars
for mode in modes.split(","): for mode in modes.split(","):
avatar = None avatar = None
if mode in mode_map: if mode in mode_map:

View File

@ -35,8 +35,8 @@ redis:
tls_reqs: "none" tls_reqs: "none"
# broker: # broker:
# url: "" # url: ""
# transport_options: "" # transport_options: ""
cache: cache:
# url: "" # url: ""
@ -46,10 +46,10 @@ cache:
timeout_reputation: 300 timeout_reputation: 300
# channel: # channel:
# url: "" # url: ""
# result_backend: # result_backend:
# url: "" # url: ""
paths: paths:
media: ./media media: ./media
@ -105,19 +105,12 @@ reputation:
cookie_domain: null cookie_domain: null
disable_update_check: false disable_update_check: false
disable_startup_analytics: false disable_startup_analytics: false
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar,initials
geoip: "/geoip/GeoLite2-City.mmdb" 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 cert_discovery_dir: /certs
default_token_length: 60 default_token_length: 60
impersonation: true
tenant_management_key: ""
blueprints_dir: /blueprints blueprints_dir: /blueprints

View File

@ -19,6 +19,7 @@ from structlog.stdlib import get_logger
from authentik import __version__, get_build_hash from authentik import __version__, get_build_hash
from authentik.blueprints.models import ManagedModel from authentik.blueprints.models import ManagedModel
from authentik.brands.models import Brand
from authentik.core.models import ( from authentik.core.models import (
USER_PATH_SYSTEM_PREFIX, USER_PATH_SYSTEM_PREFIX,
Provider, Provider,
@ -34,7 +35,6 @@ from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.outposts.controllers.k8s.utils import get_namespace from authentik.outposts.controllers.k8s.utils import get_namespace
from authentik.tenants.models import Tenant
OUR_VERSION = parse(__version__) OUR_VERSION = parse(__version__)
OUTPOST_HELLO_INTERVAL = 10 OUTPOST_HELLO_INTERVAL = 10
@ -407,9 +407,9 @@ class Outpost(SerializerModel, ManagedModel):
else: else:
objects.append(provider) objects.append(provider)
if self.managed: if self.managed:
for tenant in Tenant.objects.filter(web_certificate__isnull=False): for brand in Brand.objects.filter(web_certificate__isnull=False):
objects.append(tenant) objects.append(brand)
objects.append(tenant.web_certificate) objects.append(brand.web_certificate)
return objects return objects
def __str__(self) -> str: 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 django.dispatch import receiver
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.brands.models import Brand
from authentik.core.models import Provider from authentik.core.models import Provider
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.models import Outpost, OutpostServiceConnection
from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
UPDATE_TRIGGERING_MODELS = ( UPDATE_TRIGGERING_MODELS = (
@ -18,7 +18,7 @@ UPDATE_TRIGGERING_MODELS = (
OutpostServiceConnection, OutpostServiceConnection,
Provider, Provider,
CertificateKeyPair, CertificateKeyPair,
Tenant, Brand,
) )

View File

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

View File

@ -143,7 +143,7 @@ class PasswordPolicy(Policy):
user_inputs.append(request.user.name) user_inputs.append(request.user.name)
user_inputs.append(request.user.email) user_inputs.append(request.user.email)
if request.http_request: 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 # 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 # long passwords we can be reasonably sure that they'll surpass the score anyways
# See https://github.com/dropbox/zxcvbn#runtime-latency # See https://github.com/dropbox/zxcvbn#runtime-latency

View File

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

View File

@ -4,7 +4,7 @@ from urllib.parse import urlencode
from django.urls import reverse from django.urls import reverse
from authentik.core.models import Application 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.lib.generators import generate_id
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -28,9 +28,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
self.user = create_test_admin_user() self.user = create_test_admin_user()
self.client.force_login(self.user) self.client.force_login(self.user)
self.device_flow = create_test_flow() self.device_flow = create_test_flow()
self.tenant = create_test_tenant() self.brand = create_test_brand()
self.tenant.flow_device_code = self.device_flow self.brand.flow_device_code = self.device_flow
self.tenant.save() self.brand.save()
def test_device_init(self): def test_device_init(self):
"""Test device init""" """Test device init"""
@ -48,8 +48,8 @@ class TesOAuth2DeviceInit(OAuthTestCase):
def test_no_flow(self): def test_no_flow(self):
"""Test no flow""" """Test no flow"""
self.tenant.flow_device_code = None self.brand.flow_device_code = None
self.tenant.save() self.brand.save()
res = self.client.get(reverse("authentik_providers_oauth2_root:device-login")) res = self.client.get(reverse("authentik_providers_oauth2_root:device-login"))
self.assertEqual(res.status_code, 404) 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 rest_framework.fields import CharField, IntegerField
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.brands.models import Brand
from authentik.core.models import Application from authentik.core.models import Application
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.exceptions import FlowNonApplicableException
@ -26,7 +27,6 @@ from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS, PLAN_CONTEXT_CONSENT_PERMISSIONS,
) )
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
QS_KEY_CODE = "code" # nosec QS_KEY_CODE = "code" # nosec
@ -88,10 +88,10 @@ class DeviceEntryView(View):
"""View used to initiate the device-code flow, url entered by endusers""" """View used to initiate the device-code flow, url entered by endusers"""
def dispatch(self, request: HttpRequest) -> HttpResponse: def dispatch(self, request: HttpRequest) -> HttpResponse:
tenant: Tenant = request.tenant brand: Brand = request.brand
device_flow = tenant.flow_device_code device_flow = brand.flow_device_code
if not device_flow: 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) return HttpResponse(status=404)
if QS_KEY_CODE in request.GET: if QS_KEY_CODE in request.GET:
validation = validate_code(request.GET[QS_KEY_CODE], request) validation = validate_code(request.GET[QS_KEY_CODE], request)

View File

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

View File

@ -1,5 +1,5 @@
"""authentik database backend""" """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 from authentik.lib.config import CONFIG

View File

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

View File

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

View File

@ -89,7 +89,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
registration_options: PublicKeyCredentialCreationOptions = generate_registration_options( registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
rp_id=get_rp_id(self.request), 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_id=user.uid,
user_name=user.username, user_name=user.username,
user_display_name=user.name, user_display_name=user.name,

View File

@ -1,137 +1,114 @@
"""Serializer for tenant models""" """Serializer for tenants models"""
from typing import Any from hmac import compare_digest
from django.db import models from django_tenants.utils import get_tenant
from drf_spectacular.utils import extend_schema from rest_framework import permissions
from rest_framework.decorators import action from rest_framework.authentication import get_authorization_header
from rest_framework.exceptions import ValidationError from rest_framework.fields import ReadOnlyField
from rest_framework.fields import CharField, ChoiceField, ListField
from rest_framework.filters import OrderingFilter, SearchFilter 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.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.views import View
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import SecretKeyFilter from authentik.api.authentication import validate_auth
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.tenants.models import Tenant from authentik.tenants.models import Domain, Tenant
class FooterLinkSerializer(PassiveSerializer): class TenantManagementKeyPermission(permissions.BasePermission):
"""Links returned in Config API""" def has_permission(self, request: Request, view: View) -> bool:
token = validate_auth(get_authorization_header(request))
href = CharField(read_only=True) tenant_management_key = CONFIG.get("tenant_management_key")
name = CharField(read_only=True) if compare_digest("", tenant_management_key):
return False
return compare_digest(token, tenant_management_key)
class TenantSerializer(ModelSerializer): class TenantSerializer(ModelSerializer):
"""Tenant Serializer""" """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: class Meta:
model = Tenant model = Tenant
fields = [ fields = [
"tenant_uuid", "tenant_uuid",
"domain", "schema_name",
"default", "name",
"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): class TenantViewSet(ModelViewSet):
"""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):
"""Tenant Viewset""" """Tenant Viewset"""
queryset = Tenant.objects.all() queryset = Tenant.objects.all()
serializer_class = TenantSerializer serializer_class = TenantSerializer
search_fields = [ search_fields = [
"domain", "name",
"branding_title", "schema_name",
"web_certificate__name", "domains__domain",
] ]
filterset_fields = [ ordering = ["schema_name"]
"tenant_uuid", 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", "domain",
"default", "tenant__name",
"branding_title", "tenant__schema_name",
"branding_logo",
"branding_favicon",
"flow_authentication",
"flow_invalidation",
"flow_recovery",
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"event_retention",
"web_certificate",
] ]
ordering = ["domain"] ordering = ["domain"]
permission_classes = [TenantManagementKeyPermission]
filter_backends = [OrderingFilter, SearchFilter]
filter_backends = [SecretKeyFilter, OrderingFilter, SearchFilter]
@extend_schema( class SettingsSerializer(ModelSerializer):
responses=CurrentTenantSerializer(many=False), """Settings Serializer"""
)
@action(methods=["GET"], detail=False, permission_classes=[AllowAny]) name = ReadOnlyField()
def current(self, request: Request) -> Response: domains = DomainSerializer(read_only=True, many=True)
"""Get current tenant"""
tenant: Tenant = request._request.tenant class Meta:
return Response(CurrentTenantSerializer(tenant).data) 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 from django.apps import AppConfig
class AuthentikTenantsConfig(AppConfig): class AuthentikTenantsConfig(AppConfig):
"""authentik Tenant app""" """authentik tenants app"""
name = "authentik.tenants" name = "authentik.tenants"
label = "authentik_tenants" label = "authentik_tenants"

View File

@ -1,15 +1,13 @@
"""Inject tenant into current request""" """Inject tenant into current request"""
from typing import Callable from typing import Callable
from django.http.request import HttpRequest from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponse
from django.utils.translation import activate
from sentry_sdk.api import set_tag from sentry_sdk.api import set_tag
from authentik.tenants.utils import get_tenant_for_request from authentik.tenants.utils import get_tenant_for_request
class TenantMiddleware: class CurrentTenantMiddleware:
"""Add current tenant to http request""" """Add current tenant to http request"""
get_response: Callable[[HttpRequest], HttpResponse] get_response: Callable[[HttpRequest], HttpResponse]
@ -22,8 +20,5 @@ class TenantMiddleware:
tenant = get_tenant_for_request(request) tenant = get_tenant_for_request(request)
setattr(request, "tenant", tenant) setattr(request, "tenant", tenant)
set_tag("authentik.tenant_uuid", tenant.tenant_uuid.hex) set_tag("authentik.tenant_uuid", tenant.tenant_uuid.hex)
set_tag("authentik.tenant_domain", tenant.domain) set_tag("authentik.tenant_domain_regex", tenant.domain_regex)
locale = tenant.default_locale
if locale != "":
activate(locale)
return self.get_response(request) 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 uuid import uuid4
from django.db import models 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.utils.translation import gettext_lazy as _
from django_tenants.models import DomainMixin, TenantMixin
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger 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.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
LOGGER = get_logger() LOGGER = get_logger()
class Tenant(SerializerModel): class Tenant(TenantMixin, SerializerModel):
"""Single tenant""" """Tenant"""
tenant_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) 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=_( help_text=_(
"Domain that activates this tenant. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`" "When enabled, all the events caused by a user will be deleted upon the user's deletion."
)
)
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)."
), ),
default=True,
) )
impersonation = models.BooleanField(
web_certificate = models.ForeignKey( help_text=_("Globally enable/disable impersonation."), default=True
CertificateKeyPair, )
null=True, footer_links = models.JSONField(
default=None, help_text=_("The option configures the footer links on the flow executor pages."),
on_delete=models.SET_DEFAULT, default=list,
help_text=_("Web Certificate used by the authentik Core webserver."), 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 @property
def serializer(self) -> Serializer: def serializer(self) -> Serializer:
@ -74,21 +62,24 @@ class Tenant(SerializerModel):
return TenantSerializer 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: def __str__(self) -> str:
if self.default: return f"Tenant {self.domain_regex}"
return "Default tenant"
return f"Tenant {self.domain}"
class Meta: class Meta:
verbose_name = _("Tenant") verbose_name = _("Tenant")
verbose_name_plural = _("Tenants") 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""" """API URLs"""
from authentik.tenants.api import TenantViewSet from django.urls import path
from authentik.tenants.api import SettingsView, TenantViewSet
api_urlpatterns = [ 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 django.db import connection
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.lib.config import CONFIG
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
_q_default = Q(default=True)
DEFAULT_TENANT = Tenant(domain="fallback")
def get_current_tenant() -> Tenant:
def get_tenant_for_request(request: HttpRequest) -> Tenant: """Get tenant for current request"""
"""Get tenant object for current request""" return connection.tenant
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(),
}

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

View File

@ -41,6 +41,80 @@
"type": "array", "type": "array",
"items": { "items": {
"oneOf": [ "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", "type": "object",
"required": [ "required": [
@ -2528,7 +2602,7 @@
], ],
"properties": { "properties": {
"model": { "model": {
"const": "authentik_tenants.tenant" "const": "authentik_brands.brand"
}, },
"id": { "id": {
"type": "string" "type": "string"
@ -2550,10 +2624,10 @@
} }
}, },
"attrs": { "attrs": {
"$ref": "#/$defs/model_authentik_tenants.tenant" "$ref": "#/$defs/model_authentik_brands.brand"
}, },
"identifiers": { "identifiers": {
"$ref": "#/$defs/model_authentik_tenants.tenant" "$ref": "#/$defs/model_authentik_brands.brand"
} }
} }
}, },
@ -2821,6 +2895,43 @@
} }
}, },
"$defs": { "$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": { "model_authentik_crypto.certificatekeypair": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -2907,10 +3018,10 @@
"format": "date-time", "format": "date-time",
"title": "Expires" "title": "Expires"
}, },
"tenant": { "brand": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"title": "Tenant" "title": "Brand"
} }
}, },
"required": [] "required": []
@ -3016,10 +3127,10 @@
"format": "date-time", "format": "date-time",
"title": "Expires" "title": "Expires"
}, },
"tenant": { "brand": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"title": "Tenant" "title": "Brand"
} }
}, },
"required": [ "required": [
@ -3427,6 +3538,7 @@
], ],
"enum": [ "enum": [
null, null,
"authentik.tenants",
"authentik.admin", "authentik.admin",
"authentik.api", "authentik.api",
"authentik.crypto", "authentik.crypto",
@ -3472,7 +3584,7 @@
"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.brands",
"authentik.blueprints", "authentik.blueprints",
"authentik.core", "authentik.core",
"authentik.enterprise" "authentik.enterprise"
@ -3487,6 +3599,8 @@
], ],
"enum": [ "enum": [
null, null,
"authentik_tenants.tenant",
"authentik_tenants.domain",
"authentik_crypto.certificatekeypair", "authentik_crypto.certificatekeypair",
"authentik_events.event", "authentik_events.event",
"authentik_events.notificationtransport", "authentik_events.notificationtransport",
@ -3554,7 +3668,7 @@
"authentik_stages_user_login.userloginstage", "authentik_stages_user_login.userloginstage",
"authentik_stages_user_logout.userlogoutstage", "authentik_stages_user_logout.userlogoutstage",
"authentik_stages_user_write.userwritestage", "authentik_stages_user_write.userwritestage",
"authentik_tenants.tenant", "authentik_brands.brand",
"authentik_blueprints.blueprintinstance", "authentik_blueprints.blueprintinstance",
"authentik_core.group", "authentik_core.group",
"authentik_core.user", "authentik_core.user",
@ -8390,14 +8504,14 @@
}, },
"required": [] "required": []
}, },
"model_authentik_tenants.tenant": { "model_authentik_brands.brand": {
"type": "object", "type": "object",
"properties": { "properties": {
"domain": { "domain": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"title": "Domain", "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": { "default": {
"type": "boolean", "type": "boolean",

View File

@ -19,7 +19,7 @@ import (
sentryutils "goauthentik.io/internal/utils/sentry" sentryutils "goauthentik.io/internal/utils/sentry"
webutils "goauthentik.io/internal/utils/web" webutils "goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web" "goauthentik.io/internal/web"
"goauthentik.io/internal/web/tenant_tls" "goauthentik.io/internal/web/brand_tls"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@ -95,11 +95,11 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) {
} }
continue 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 // 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() go tw.Start()
ws.TenantTLS = tw ws.BrandTLS = tw
ac.AddRefreshHandler(func() { ac.AddRefreshHandler(func() {
tw.Check() tw.Check()
}) })

View File

@ -30,9 +30,9 @@ func (ls *LDAPServer) getCurrentProvider(pk int32) *ProviderInstance {
} }
func (ls *LDAPServer) getInvalidationFlow() string { 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 { if err != nil {
ls.log.WithError(err).Warning("failed to fetch tenant config") ls.log.WithError(err).Warning("failed to fetch brand config")
return "" return ""
} }
flow := req.GetFlowInvalidation() flow := req.GetFlowInvalidation()

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
#!/bin/bash -e #!/usr/bin/env -S bash -e
MODE_FILE="${TMPDIR}/authentik-mode" MODE_FILE="${TMPDIR}/authentik-mode"
function log { 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-model-utils = "*"
django-prometheus = "*" django-prometheus = "*"
django-redis = "*" django-redis = "*"
django-tenants = { git = "https://github.com/hho6643/django-tenants.git", branch="hho6643-psycopg3_fixes" }
djangorestframework = "*" djangorestframework = "*"
djangorestframework-guardian = "*" djangorestframework-guardian = "*"
docker = "*" 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 { export class LdapForm extends Page {
async setBindFlow(selector: string) { async setBindFlow(selector: string) {
await this.searchSelect( await this.searchSelect(
'>>>ak-tenanted-flow-search[name="authorizationFlow"] input[type="text"]', '>>>ak-branded-flow-search[name="authorizationFlow"] input[type="text"]',
"authorizationFlow", "authorizationFlow",
`button*=${selector}`, `button*=${selector}`,
); );

View File

@ -3,7 +3,7 @@ import Page from "../page.js";
export class RadiusForm extends Page { export class RadiusForm extends Page {
async setAuthenticationFlow(selector: string) { async setAuthenticationFlow(selector: string) {
await this.searchSelect( await this.searchSelect(
'>>>ak-tenanted-flow-search[name="authorizationFlow"] input[type="text"]', '>>>ak-branded-flow-search[name="authorizationFlow"] input[type="text"]',
"authorizationFlow", "authorizationFlow",
`button*=${selector}`, `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"); await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`; return html`<ak-token-list></ak-token-list>`;
}), }),
new Route(new RegExp("^/core/tenants$"), async () => { new Route(new RegExp("^/core/brands"), async () => {
await import("@goauthentik/admin/tenants/TenantListPage"); await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-tenant-list></ak-tenant-list>`; return html`<ak-brand-list></ak-brand-list>`;
}), }),
new Route(new RegExp("^/policy/policies$"), async () => { new Route(new RegExp("^/policy/policies$"), async () => {
await import("@goauthentik/admin/policies/PolicyListPage"); 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("User"), "user"),
new TableColumn(msg("Creation Date"), "created"), new TableColumn(msg("Creation Date"), "created"),
new TableColumn(msg("Client IP"), "client_ip"), 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`<span>${item.created?.toLocaleString()}</span>`,
html` <div>${item.clientIp || msg("-")}</div> html` <div>${item.clientIp || msg("-")}</div>
<small>${EventGeo(item)}</small>`, <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-core-group-search";
import "@goauthentik/admin/common/ak-crypto-certificate-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 { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-number-input"; import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
@ -49,12 +49,12 @@ export class ApplicationWizardApplicationDetails extends BaseProviderPanel {
?required=${true} ?required=${true}
name="authorizationFlow" name="authorizationFlow"
> >
<ak-tenanted-flow-search <ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication} flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authorizationFlow} .currentFlow=${provider?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication} .brandFlow=${rootInterface()?.brand?.flowAuthentication}
required required
></ak-tenanted-flow-search> ></ak-branded-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p> <p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>

View File

@ -1,5 +1,5 @@
import "@goauthentik/admin/common/ak-crypto-certificate-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-branted-flow-search";
import { import {
clientTypeOptions, clientTypeOptions,
issuerModeOptions, issuerModeOptions,

View File

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

View File

@ -1,6 +1,6 @@
import "@goauthentik/admin/common/ak-core-group-search"; import "@goauthentik/admin/common/ak-core-group-search";
import "@goauthentik/admin/common/ak-crypto-certificate-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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-number-input"; import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-radio-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-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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";

View File

@ -8,40 +8,40 @@ import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/forms/SearchSelect";
import { DefaultTenant } from "@goauthentik/elements/sidebar/SidebarBrand"; import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
import YAML from "yaml"; import YAML from "yaml";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import { CoreApi, FlowsInstancesListDesignationEnum, Tenant } from "@goauthentik/api"; import { Brand, CoreApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
@customElement("ak-tenant-form") @customElement("ak-brand-form")
export class TenantForm extends ModelForm<Tenant, string> { export class BrandForm extends ModelForm<Brand, string> {
loadInstance(pk: string): Promise<Tenant> { loadInstance(pk: string): Promise<Brand> {
return new CoreApi(DEFAULT_CONFIG).coreTenantsRetrieve({ return new CoreApi(DEFAULT_CONFIG).coreBrandsRetrieve({
tenantUuid: pk, brandUuid: pk,
}); });
} }
getSuccessMessage(): string { getSuccessMessage(): string {
if (this.instance) { if (this.instance) {
return msg("Successfully updated tenant."); return msg("Successfully updated brand.");
} else { } else {
return msg("Successfully created tenant."); return msg("Successfully created brand.");
} }
} }
async send(data: Tenant): Promise<Tenant> { async send(data: Brand): Promise<Brand> {
if (this.instance?.tenantUuid) { if (this.instance?.brandUuid) {
return new CoreApi(DEFAULT_CONFIG).coreTenantsUpdate({ return new CoreApi(DEFAULT_CONFIG).coreBrandsUpdate({
tenantUuid: this.instance.tenantUuid, brandUuid: this.instance.brandUuid,
tenantRequest: data, brandRequest: data,
}); });
} else { } else {
return new CoreApi(DEFAULT_CONFIG).coreTenantsCreate({ return new CoreApi(DEFAULT_CONFIG).coreBrandsCreate({
tenantRequest: data, brandRequest: data,
}); });
} }
} }
@ -79,7 +79,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
<span class="pf-c-switch__label">${msg("Default")}</span> <span class="pf-c-switch__label">${msg("Default")}</span>
</label> </label>
<p class="pf-c-form__helper-text"> <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> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
@ -95,7 +95,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
type="text" type="text"
value="${first( value="${first(
this.instance?.brandingTitle, this.instance?.brandingTitle,
DefaultTenant.brandingTitle, DefaultBrand.brandingTitle,
)}" )}"
class="pf-c-form-control" class="pf-c-form-control"
required required
@ -111,10 +111,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
> >
<input <input
type="text" type="text"
value="${first( value="${first(this.instance?.brandingLogo, DefaultBrand.brandingLogo)}"
this.instance?.brandingLogo,
DefaultTenant.brandingLogo,
)}"
class="pf-c-form-control" class="pf-c-form-control"
required required
/> />
@ -131,7 +128,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
type="text" type="text"
value="${first( value="${first(
this.instance?.brandingFavicon, this.instance?.brandingFavicon,
DefaultTenant.brandingFavicon, DefaultBrand.brandingFavicon,
)}" )}"
class="pf-c-form-control" class="pf-c-form-control"
required required
@ -274,7 +271,7 @@ export class TenantForm extends ModelForm<Tenant, string> {
</ak-codemirror> </ak-codemirror>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${msg( ${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> </p>
</ak-form-element-horizontal> </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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config"; import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
@ -16,21 +16,21 @@ import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js"; 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") @customElement("ak-brand-list")
export class TenantListPage extends TablePage<Tenant> { export class BrandListPage extends TablePage<Brand> {
searchEnabled(): boolean { searchEnabled(): boolean {
return true; return true;
} }
pageTitle(): string { pageTitle(): string {
return msg("Tenants"); return msg("Brands");
} }
pageDescription(): string { pageDescription(): string {
return msg("Configure visual settings and defaults for different domains."); return msg("Configure visual settings and defaults for different domains.");
} }
pageIcon(): string { pageIcon(): string {
return "pf-icon pf-icon-tenant"; return "pf-icon pf-icon-brand";
} }
checkbox = true; checkbox = true;
@ -38,8 +38,8 @@ export class TenantListPage extends TablePage<Tenant> {
@property() @property()
order = "domain"; order = "domain";
async apiEndpoint(page: number): Promise<PaginatedResponse<Tenant>> { async apiEndpoint(page: number): Promise<PaginatedResponse<Brand>> {
return new CoreApi(DEFAULT_CONFIG).coreTenantsList({ return new CoreApi(DEFAULT_CONFIG).coreBrandsList({
ordering: this.order, ordering: this.order,
page: page, page: page,
pageSize: (await uiConfig()).pagination.perPage, pageSize: (await uiConfig()).pagination.perPage,
@ -58,19 +58,19 @@ export class TenantListPage extends TablePage<Tenant> {
renderToolbarSelected(): TemplateResult { renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1; const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk return html`<ak-forms-delete-bulk
objectLabel=${msg("Tenant(s)")} objectLabel=${msg("Brand(s)")}
.objects=${this.selectedElements} .objects=${this.selectedElements}
.metadata=${(item: Tenant) => { .metadata=${(item: Brand) => {
return [{ key: msg("Domain"), value: item.domain }]; return [{ key: msg("Domain"), value: item.domain }];
}} }}
.usedBy=${(item: Tenant) => { .usedBy=${(item: Brand) => {
return new CoreApi(DEFAULT_CONFIG).coreTenantsUsedByList({ return new CoreApi(DEFAULT_CONFIG).coreBrandsUsedByList({
tenantUuid: item.tenantUuid, brandUuid: item.brandUuid,
}); });
}} }}
.delete=${(item: Tenant) => { .delete=${(item: Brand) => {
return new CoreApi(DEFAULT_CONFIG).coreTenantsDestroy({ return new CoreApi(DEFAULT_CONFIG).coreBrandsDestroy({
tenantUuid: item.tenantUuid, brandUuid: item.brandUuid,
}); });
}} }}
> >
@ -80,14 +80,14 @@ export class TenantListPage extends TablePage<Tenant> {
</ak-forms-delete-bulk>`; </ak-forms-delete-bulk>`;
} }
row(item: Tenant): TemplateResult[] { row(item: Brand): TemplateResult[] {
return [ return [
html`${item.domain}`, html`${item.domain}`,
html`<ak-status-label ?good=${item._default}></ak-status-label>`, html`<ak-status-label ?good=${item._default}></ak-status-label>`,
html`<ak-forms-modal> html`<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span> <span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update Tenant")} </span> <span slot="header"> ${msg("Update Brand")} </span>
<ak-tenant-form slot="form" .instancePk=${item.tenantUuid}> </ak-tenant-form> <ak-brand-form slot="form" .instancePk=${item.brandUuid}> </ak-brand-form>
<button slot="trigger" class="pf-c-button pf-m-plain"> <button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}> <pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
@ -96,8 +96,8 @@ export class TenantListPage extends TablePage<Tenant> {
</ak-forms-modal> </ak-forms-modal>
<ak-rbac-object-permission-modal <ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.TenantsTenant} model=${RbacPermissionsAssignedByUsersListModelEnum.BrandsBrand}
objectPk=${item.tenantUuid} objectPk=${item.brandUuid}
> >
</ak-rbac-object-permission-modal>`, </ak-rbac-object-permission-modal>`,
]; ];
@ -107,8 +107,8 @@ export class TenantListPage extends TablePage<Tenant> {
return html` return html`
<ak-forms-modal> <ak-forms-modal>
<span slot="submit"> ${msg("Create")} </span> <span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Tenant")} </span> <span slot="header"> ${msg("Create Brand")} </span>
<ak-tenant-form slot="form"> </ak-tenant-form> <ak-brand-form slot="form"> </ak-brand-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button> <button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal> </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 * 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 * 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 /* 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. * 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("User"), "user"),
new TableColumn(msg("Creation Date"), "created"), new TableColumn(msg("Creation Date"), "created"),
new TableColumn(msg("Client IP"), "client_ip"), new TableColumn(msg("Client IP"), "client_ip"),
new TableColumn(msg("Tenant"), "tenant_name"), new TableColumn(msg("Brand"), "brand_name"),
new TableColumn(msg("Actions")), new TableColumn(msg("Actions")),
]; ];
} }
@ -75,7 +75,7 @@ export class EventListPage extends TablePage<Event> {
html`<div>${item.clientIp || msg("-")}</div> html`<div>${item.clientIp || msg("-")}</div>
<small>${EventGeo(item)}</small>`, <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}"> html`<a href="#/events/log/${item.pk}">
<pf-tooltip position="top" content=${msg("Show details")}> <pf-tooltip position="top" content=${msg("Show details")}>
<i class="fas fa-share-square"></i> <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"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text" <span class="pf-c-description-list__text"
>${msg("Tenant")}</span >${msg("Brand")}</span
> >
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
${this.event.tenant?.name || msg("-")} ${this.event.brand?.name || msg("-")}
</div> </div>
</dd> </dd>
</div> </div>

View File

@ -293,7 +293,7 @@ export class RelatedUserList extends Table<User> {
${msg("Set password")} ${msg("Set password")}
</button> </button>
</ak-forms-modal> </ak-forms-modal>
${rootInterface()?.tenant?.flowRecovery ${rootInterface()?.brand?.flowRecovery
? html` ? html`
<ak-action-button <ak-action-button
class="pf-m-secondary" class="pf-m-secondary"
@ -355,7 +355,7 @@ export class RelatedUserList extends Table<User> {
` `
: html` <p> : html` <p>
${msg( ${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>`} </p>`}
</div> </div>

View File

@ -1,5 +1,5 @@
import "@goauthentik/admin/common/ak-crypto-certificate-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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import { rootInterface } from "@goauthentik/elements/Base"; 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 // 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 // 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 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 // weird-- we're looking up Authentication flows, but we're storing them in the Authorization
// field of the target Provider. // field of the target Provider.
renderForm(): TemplateResult { renderForm(): TemplateResult {
@ -73,12 +73,12 @@ export class LDAPProviderFormPage extends ModelForm<LDAPProvider, number> {
?required=${true} ?required=${true}
name="authorizationFlow" name="authorizationFlow"
> >
<ak-tenanted-flow-search <ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication} flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${this.instance?.authorizationFlow} .currentFlow=${this.instance?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication} .brandFlow=${rootInterface()?.brand?.flowAuthentication}
required required
></ak-tenanted-flow-search> ></ak-branded-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p> <p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Search group")} name="searchGroup"> <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 // 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 // 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 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 // weird-- we're looking up Authentication flows, but we're storing them in the Authorization
// field of the target Provider. // field of the target Provider.
renderForm(): TemplateResult { renderForm(): TemplateResult {
@ -62,12 +62,12 @@ export class RadiusProviderFormPage extends ModelForm<RadiusProvider, number> {
?required=${true} ?required=${true}
name="authorizationFlow" name="authorizationFlow"
> >
<ak-tenanted-flow-search <ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication} flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${this.instance?.authorizationFlow} .currentFlow=${this.instance?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication} .brandFlow=${rootInterface()?.brand?.flowAuthentication}
required required
></ak-tenanted-flow-search> ></ak-branded-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p> <p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal name="mfaSupport"> <ak-form-element-horizontal name="mfaSupport">

View File

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

View File

@ -63,7 +63,7 @@ export const requestRecoveryLink = (user: User) =>
showMessage({ showMessage({
level: MessageLevel.error, level: MessageLevel.error,
message: msg( 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")} ${msg("Set password")}
</button> </button>
</ak-forms-modal> </ak-forms-modal>
${rootInterface()?.tenant?.flowRecovery ${rootInterface()?.brand?.flowRecovery
? html` ? html`
<ak-action-button <ak-action-button
class="pf-m-secondary" class="pf-m-secondary"
@ -373,7 +373,7 @@ export class UserListPage extends TablePage<User> {
` `
: html` <p> : html` <p>
${msg( ${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>`} </p>`}
</div> </div>

View File

@ -6,7 +6,7 @@ import {
import { EVENT_LOCALE_REQUEST, EVENT_REFRESH, VERSION } from "@goauthentik/common/constants"; import { EVENT_LOCALE_REQUEST, EVENT_REFRESH, VERSION } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global"; 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); let globalConfigPromise: Promise<Config> | undefined = Promise.resolve(globalAK().config);
export function config(): Promise<Config> { export function config(): Promise<Config> {
@ -16,7 +16,7 @@ export function config(): Promise<Config> {
return globalConfigPromise; return globalConfigPromise;
} }
export function tenantSetFavicon(tenant: CurrentTenant) { export function brandSetFavicon(brand: CurrentBrand) {
/** /**
* <link rel="icon" href="/static/dist/assets/icons/icon.png"> * <link rel="icon" href="/static/dist/assets/icons/icon.png">
* <link rel="shortcut 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; relIcon.rel = rel;
document.getElementsByTagName("head")[0].appendChild(relIcon); document.getElementsByTagName("head")[0].appendChild(relIcon);
} }
relIcon.href = tenant.brandingFavicon; relIcon.href = brand.brandingFavicon;
}); });
} }
export function tenantSetLocale(tenant: CurrentTenant) { export function brandSetLocale(brand: CurrentBrand) {
if (tenant.defaultLocale === "") { if (brand.defaultLocale === "") {
return; return;
} }
console.debug("authentik/locale: setting locale from tenant default"); console.debug("authentik/locale: setting locale from brand default");
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, { new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true, composed: true,
bubbles: true, bubbles: true,
detail: { locale: tenant.defaultLocale }, detail: { locale: brand.defaultLocale },
}), }),
); );
} }
let globalTenantPromise: Promise<CurrentTenant> | undefined = Promise.resolve(globalAK().tenant); let globalBrandPromise: Promise<CurrentBrand> | undefined = Promise.resolve(globalAK().brand);
export function tenant(): Promise<CurrentTenant> { export function brand(): Promise<CurrentBrand> {
if (!globalTenantPromise) { if (!globalBrandPromise) {
globalTenantPromise = new CoreApi(DEFAULT_CONFIG) globalBrandPromise = new CoreApi(DEFAULT_CONFIG)
.coreTenantsCurrentRetrieve() .coreBrandsCurrentRetrieve()
.then((tenant) => { .then((brand) => {
tenantSetFavicon(tenant); brandSetFavicon(brand);
tenantSetLocale(tenant); brandSetLocale(brand);
return tenant; return brand;
}); });
} }
return globalTenantPromise; return globalBrandPromise;
} }
export function getMetaContent(key: string): string { export function getMetaContent(key: string): string {
@ -75,7 +75,7 @@ export const DEFAULT_CONFIG = new Configuration({
middleware: [ middleware: [
new CSRFMiddleware(), new CSRFMiddleware(),
new EventMiddleware(), 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 // Upon global refresh, disregard whatever was pre-hydrated and
// actually load info from API // actually load info from API
globalConfigPromise = undefined; globalConfigPromise = undefined;
globalTenantPromise = undefined; globalBrandPromise = undefined;
config(); config();
tenant(); brand();
}); });
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`); 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 { getCookie } from "@goauthentik/common/utils";
import { import {
CurrentTenant, CurrentBrand,
FetchParams, FetchParams,
Middleware, Middleware,
RequestContext, RequestContext,
@ -18,13 +18,13 @@ export interface RequestInfo {
} }
export class LoggingMiddleware implements Middleware { export class LoggingMiddleware implements Middleware {
tenant: CurrentTenant; brand: CurrentBrand;
constructor(tenant: CurrentTenant) { constructor(brand: CurrentBrand) {
this.tenant = tenant; this.brand = brand;
} }
post(context: ResponseContext): Promise<Response | void> { 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 // https://developer.mozilla.org/en-US/docs/Web/API/console#styling_console_output
msg += `%c${context.response.status}%c ${context.init.method} ${context.url}`; msg += `%c${context.response.status}%c ${context.init.method} ${context.url}`;
let style = ""; let style = "";

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