diff --git a/Makefile b/Makefile index 0a82f974e4..e72db46279 100644 --- a/Makefile +++ b/Makefile @@ -96,8 +96,14 @@ dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik ######################### gen-build: ## Extract the schema from the database - AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json - AUTHENTIK_DEBUG=true ak spectacular --file schema.yml + AUTHENTIK_DEBUG=true \ + AUTHENTIK_TENANTS__ENABLED=true \ + AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ + ak make_blueprint_schema > blueprints/schema.json + AUTHENTIK_DEBUG=true \ + AUTHENTIK_TENANTS__ENABLED=true \ + AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ + ak spectacular --file schema.yml gen-changelog: ## (Release) generate the changelog based from the commits since the last tag git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md @@ -116,12 +122,16 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a sed -i 's/}/}/g' diff.md npx prettier --write diff.md -gen-clean: - rm -rf gen-go-api/ +gen-clean-ts: ## Remove generated API client for Typescript rm -rf gen-ts-api/ rm -rf web/node_modules/@goauthentik/api/ -gen-client-ts: ## Build and install the authentik API for Typescript into the authentik UI Application +gen-clean-go: ## Remove generated API client for Go + rm -rf gen-go-api/ + +gen-clean: gen-clean-ts gen-clean-go ## Remove generated API clients + +gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application docker run \ --rm -v ${PWD}:/local \ --user ${UID}:${GID} \ @@ -137,7 +147,7 @@ gen-client-ts: ## Build and install the authentik API for Typescript into the a cd gen-ts-api && npm i \cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api -gen-client-go: ## Build and install the authentik API for Golang +gen-client-go: gen-clean-go ## Build and install the authentik API for Golang mkdir -p ./gen-go-api ./gen-go-api/templates wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./gen-go-api/config.yaml wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./gen-go-api/templates/README.mustache @@ -157,7 +167,7 @@ gen-client-go: ## Build and install the authentik API for Golang gen-dev-config: ## Generate a local development config file python -m scripts.generate_config -gen: gen-build gen-clean gen-client-ts +gen: gen-build gen-client-ts ######################### ## Web diff --git a/authentik/admin/api/system.py b/authentik/admin/api/system.py index 5a70071539..16637067a2 100644 --- a/authentik/admin/api/system.py +++ b/authentik/admin/api/system.py @@ -13,6 +13,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from authentik.core.api.utils import PassiveSerializer +from authentik.lib.config import CONFIG from authentik.lib.utils.reflection import get_env from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.models import Outpost @@ -37,8 +38,9 @@ class SystemInfoSerializer(PassiveSerializer): http_host = SerializerMethodField() http_is_secure = SerializerMethodField() runtime = SerializerMethodField() - tenant = SerializerMethodField() + brand = SerializerMethodField() server_time = SerializerMethodField() + embedded_outpost_disabled = SerializerMethodField() embedded_outpost_host = SerializerMethodField() def get_http_headers(self, request: Request) -> dict[str, str]: @@ -69,14 +71,18 @@ class SystemInfoSerializer(PassiveSerializer): "uname": " ".join(platform.uname()), } - def get_tenant(self, request: Request) -> str: - """Currently active tenant""" - return str(request._request.tenant) + def get_brand(self, request: Request) -> str: + """Currently active brand""" + return str(request._request.brand) def get_server_time(self, request: Request) -> datetime: """Current server time""" return now() + def get_embedded_outpost_disabled(self, request: Request) -> bool: + """Whether the embedded outpost is disabled""" + return CONFIG.get_bool("outposts.disable_embedded_outpost", False) + def get_embedded_outpost_host(self, request: Request) -> str: """Get the FQDN configured on the embedded outpost""" outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) diff --git a/authentik/admin/apps.py b/authentik/admin/apps.py index a80e21d892..6cc7ba8655 100644 --- a/authentik/admin/apps.py +++ b/authentik/admin/apps.py @@ -15,6 +15,6 @@ class AuthentikAdminConfig(ManagedAppConfig): verbose_name = "authentik Admin" default = True - def reconcile_load_admin_signals(self): + def reconcile_global_load_admin_signals(self): """Load admin signals""" self.import_module("authentik.admin.signals") diff --git a/authentik/api/templates/api/browser.html b/authentik/api/templates/api/browser.html index 2b4ed50fd1..9434db93db 100644 --- a/authentik/api/templates/api/browser.html +++ b/authentik/api/templates/api/browser.html @@ -3,7 +3,7 @@ {% load static %} {% block title %} -API Browser - {{ tenant.branding_title }} +API Browser - {{ brand.branding_title }} {% endblock %} {% block head %} diff --git a/authentik/api/v3/config.py b/authentik/api/v3/config.py index 93b7836295..49493234bc 100644 --- a/authentik/api/v3/config.py +++ b/authentik/api/v3/config.py @@ -72,7 +72,7 @@ class ConfigView(APIView): for processor in get_context_processors(): if cap := processor.capability(): caps.append(cap) - if CONFIG.get_bool("impersonation"): + if self.request.tenant.impersonation: caps.append(Capabilities.CAN_IMPERSONATE) if settings.DEBUG: # pragma: no cover caps.append(Capabilities.CAN_DEBUG) diff --git a/authentik/blueprints/apps.py b/authentik/blueprints/apps.py index aba14d5529..b013f3e371 100644 --- a/authentik/blueprints/apps.py +++ b/authentik/blueprints/apps.py @@ -13,21 +13,23 @@ class ManagedAppConfig(AppConfig): _logger: BoundLogger + RECONCILE_GLOBAL_PREFIX: str = "reconcile_global_" + RECONCILE_TENANT_PREFIX: str = "reconcile_tenant_" + def __init__(self, app_name: str, *args, **kwargs) -> None: super().__init__(app_name, *args, **kwargs) self._logger = get_logger().bind(app_name=app_name) def ready(self) -> None: - self.reconcile() + self.reconcile_global() + self.reconcile_tenant() return super().ready() def import_module(self, path: str): """Load module""" import_module(path) - def reconcile(self) -> None: - """reconcile ourselves""" - prefix = "reconcile_" + def _reconcile(self, prefix: str) -> None: for meth_name in dir(self): meth = getattr(self, meth_name) if not ismethod(meth): @@ -42,6 +44,29 @@ class ManagedAppConfig(AppConfig): except (DatabaseError, ProgrammingError, InternalError) as exc: self._logger.warning("Failed to run reconcile", name=name, exc=exc) + def reconcile_tenant(self) -> None: + """reconcile ourselves for tenanted methods""" + from authentik.tenants.models import Tenant + + try: + tenants = list(Tenant.objects.filter(ready=True)) + except (DatabaseError, ProgrammingError, InternalError) as exc: + self._logger.debug("Failed to get tenants to run reconcile", exc=exc) + return + for tenant in tenants: + with tenant: + self._reconcile(self.RECONCILE_TENANT_PREFIX) + + def reconcile_global(self) -> None: + """ + reconcile ourselves for global methods. + Used for signals, tasks, etc. Database queries should not be made in here. + """ + from django_tenants.utils import get_public_schema_name, schema_context + + with schema_context(get_public_schema_name()): + self._reconcile(self.RECONCILE_GLOBAL_PREFIX) + class AuthentikBlueprintsConfig(ManagedAppConfig): """authentik Blueprints app""" @@ -51,11 +76,11 @@ class AuthentikBlueprintsConfig(ManagedAppConfig): verbose_name = "authentik Blueprints" default = True - def reconcile_load_blueprints_v1_tasks(self): + def reconcile_global_load_blueprints_v1_tasks(self): """Load v1 tasks""" self.import_module("authentik.blueprints.v1.tasks") - def reconcile_blueprints_discovery(self): + def reconcile_tenant_blueprints_discovery(self): """Run blueprint discovery""" from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints diff --git a/authentik/blueprints/management/commands/apply_blueprint.py b/authentik/blueprints/management/commands/apply_blueprint.py index acc9ffbec5..adc76c207c 100644 --- a/authentik/blueprints/management/commands/apply_blueprint.py +++ b/authentik/blueprints/management/commands/apply_blueprint.py @@ -6,6 +6,7 @@ from structlog.stdlib import get_logger from authentik.blueprints.models import BlueprintInstance from authentik.blueprints.v1.importer import Importer +from authentik.tenants.models import Tenant LOGGER = get_logger() @@ -16,14 +17,16 @@ class Command(BaseCommand): @no_translations def handle(self, *args, **options): """Apply all blueprints in order, abort when one fails to import""" - for blueprint_path in options.get("blueprints", []): - content = BlueprintInstance(path=blueprint_path).retrieve() - importer = Importer.from_string(content) - valid, _ = importer.validate() - if not valid: - self.stderr.write("blueprint invalid") - sys_exit(1) - importer.apply() + for tenant in Tenant.objects.filter(ready=True): + with tenant: + for blueprint_path in options.get("blueprints", []): + content = BlueprintInstance(path=blueprint_path).retrieve() + importer = Importer.from_string(content) + valid, _ = importer.validate() + if not valid: + self.stderr.write("blueprint invalid") + sys_exit(1) + importer.apply() def add_arguments(self, parser): parser.add_argument("blueprints", nargs="+", type=str) diff --git a/authentik/blueprints/management/commands/export_blueprint.py b/authentik/blueprints/management/commands/export_blueprint.py index d4b29304a6..a7a0ccdf83 100644 --- a/authentik/blueprints/management/commands/export_blueprint.py +++ b/authentik/blueprints/management/commands/export_blueprint.py @@ -1,17 +1,18 @@ """Export blueprint of current authentik install""" -from django.core.management.base import BaseCommand, no_translations +from django.core.management.base import no_translations from structlog.stdlib import get_logger from authentik.blueprints.v1.exporter import Exporter +from authentik.tenants.management import TenantCommand LOGGER = get_logger() -class Command(BaseCommand): +class Command(TenantCommand): """Export blueprint of current authentik install""" @no_translations - def handle(self, *args, **options): + def handle_per_tenant(self, *args, **options): """Export blueprint of current authentik install""" exporter = Exporter() self.stdout.write(exporter.export_to_string()) diff --git a/authentik/blueprints/migrations/0001_initial.py b/authentik/blueprints/migrations/0001_initial.py index 8f6fb1a0f5..c8373214a3 100644 --- a/authentik/blueprints/migrations/0001_initial.py +++ b/authentik/blueprints/migrations/0001_initial.py @@ -14,7 +14,7 @@ from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM from authentik.lib.config import CONFIG -def check_blueprint_v1_file(BlueprintInstance: type, path: Path): +def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path): """Check if blueprint should be imported""" from authentik.blueprints.models import BlueprintInstanceStatus from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata @@ -29,7 +29,9 @@ def check_blueprint_v1_file(BlueprintInstance: type, path: Path): if version != 1: return blueprint_file.seek(0) - instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first() + instance: BlueprintInstance = ( + BlueprintInstance.objects.using(db_alias).filter(path=path).first() + ) rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir"))) meta = None if metadata: @@ -37,7 +39,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, path: Path): if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false": return if not instance: - instance = BlueprintInstance( + BlueprintInstance.objects.using(db_alias).create( name=meta.name if meta else str(rel_path), path=str(rel_path), context={}, @@ -47,7 +49,6 @@ def check_blueprint_v1_file(BlueprintInstance: type, path: Path): last_applied_hash="", metadata=metadata or {}, ) - instance.save() def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): @@ -56,7 +57,7 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit db_alias = schema_editor.connection.alias for file in glob(f"{CONFIG.get('blueprints_dir')}/**/*.yaml", recursive=True): - check_blueprint_v1_file(BlueprintInstance, Path(file)) + check_blueprint_v1_file(BlueprintInstance, db_alias, Path(file)) for blueprint in BlueprintInstance.objects.using(db_alias).all(): # If we already have flows (and we should always run before flow migrations) diff --git a/authentik/blueprints/tests/__init__.py b/authentik/blueprints/tests/__init__.py index 138e022423..e407db13a8 100644 --- a/authentik/blueprints/tests/__init__.py +++ b/authentik/blueprints/tests/__init__.py @@ -38,7 +38,7 @@ def reconcile_app(app_name: str): def wrapper(*args, **kwargs): config = apps.get_app_config(app_name) if isinstance(config, ManagedAppConfig): - config.reconcile() + config.ready() return func(*args, **kwargs) return wrapper diff --git a/authentik/blueprints/tests/test_packaged.py b/authentik/blueprints/tests/test_packaged.py index c1e65e5270..e94c750172 100644 --- a/authentik/blueprints/tests/test_packaged.py +++ b/authentik/blueprints/tests/test_packaged.py @@ -7,16 +7,16 @@ from django.test import TransactionTestCase from authentik.blueprints.models import BlueprintInstance from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.v1.importer import Importer -from authentik.tenants.models import Tenant +from authentik.brands.models import Brand class TestPackaged(TransactionTestCase): """Empty class, test methods are added dynamically""" - @apply_blueprint("default/default-tenant.yaml") + @apply_blueprint("default/default-brand.yaml") def test_decorator_static(self): """Test @apply_blueprint decorator""" - self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists()) + self.assertTrue(Brand.objects.filter(domain="authentik-default").exists()) def blueprint_tester(file_name: Path) -> Callable: diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 13ee740633..058b05b29e 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -43,6 +43,7 @@ from authentik.lib.sentry import SentryIgnoredException from authentik.outposts.models import OutpostServiceConnection from authentik.policies.models import Policy, PolicyBindingModel from authentik.providers.scim.models import SCIMGroup, SCIMUser +from authentik.tenants.models import Tenant # Context set when the serializer is created in a blueprint context # Update website/developer-docs/blueprints/v1/models.md when used @@ -57,6 +58,7 @@ def excluded_models() -> list[type[Model]]: from django.contrib.auth.models import User as DjangoUser return ( + Tenant, DjangoUser, DjangoGroup, # Base classes diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index 686e4747c9..39eefe64bc 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -38,6 +38,7 @@ from authentik.events.monitored_tasks import ( from authentik.events.utils import sanitize_dict from authentik.lib.config import CONFIG from authentik.root.celery import CELERY_APP +from authentik.tenants.models import Tenant LOGGER = get_logger() _file_watcher_started = False @@ -78,13 +79,18 @@ class BlueprintEventHandler(FileSystemEventHandler): root = Path(CONFIG.get("blueprints_dir")).absolute() path = Path(event.src_path).absolute() rel_path = str(path.relative_to(root)) - if isinstance(event, FileCreatedEvent): - LOGGER.debug("new blueprint file created, starting discovery", path=rel_path) - blueprints_discovery.delay(rel_path) - if isinstance(event, FileModifiedEvent): - for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True): - LOGGER.debug("modified blueprint file, starting apply", instance=instance) - apply_blueprint.delay(instance.pk.hex) + for tenant in Tenant.objects.filter(ready=True): + with tenant: + root = Path(CONFIG.get("blueprints_dir")).absolute() + path = Path(event.src_path).absolute() + rel_path = str(path.relative_to(root)) + if isinstance(event, FileCreatedEvent): + LOGGER.debug("new blueprint file created, starting discovery", path=rel_path) + blueprints_discovery.delay(rel_path) + if isinstance(event, FileModifiedEvent): + for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True): + LOGGER.debug("modified blueprint file, starting apply", instance=instance) + apply_blueprint.delay(instance.pk.hex) @CELERY_APP.task( diff --git a/authentik/brands/__init__.py b/authentik/brands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/tenants/api.py b/authentik/brands/api.py similarity index 75% rename from authentik/tenants/api.py rename to authentik/brands/api.py index f777639701..2b22a35089 100644 --- a/authentik/tenants/api.py +++ b/authentik/brands/api.py @@ -1,4 +1,4 @@ -"""Serializer for tenant models""" +"""Serializer for brands models""" from typing import Any from django.db import models @@ -14,10 +14,10 @@ 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.models import Tenant +from authentik.tenants.utils import get_current_tenant class FooterLinkSerializer(PassiveSerializer): @@ -27,22 +27,22 @@ class FooterLinkSerializer(PassiveSerializer): name = CharField(read_only=True) -class TenantSerializer(ModelSerializer): - """Tenant Serializer""" +class BrandSerializer(ModelSerializer): + """Brand Serializer""" def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: if attrs.get("default", False): - tenants = Tenant.objects.filter(default=True) + brands = Brand.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."}) + 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 = Tenant + model = Brand fields = [ - "tenant_uuid", + "brand_uuid", "domain", "default", "branding_title", @@ -54,7 +54,6 @@ class TenantSerializer(ModelSerializer): "flow_unenrollment", "flow_user_settings", "flow_device_code", - "event_retention", "web_certificate", "attributes", ] @@ -68,8 +67,13 @@ class Themes(models.TextChoices): DARK = "dark" -class CurrentTenantSerializer(PassiveSerializer): - """Partial tenant information for styling""" +def get_default_ui_footer_links(): + """Get default UI footer links based on current tenant settings""" + return get_current_tenant().footer_links + + +class CurrentBrandSerializer(PassiveSerializer): + """Partial brand information for styling""" matched_domain = CharField(source="domain") branding_title = CharField() @@ -78,7 +82,7 @@ class CurrentTenantSerializer(PassiveSerializer): ui_footer_links = ListField( child=FooterLinkSerializer(), read_only=True, - default=CONFIG.get("footer_links", []), + default=get_default_ui_footer_links, ) ui_theme = ChoiceField( choices=Themes.choices, @@ -97,18 +101,18 @@ class CurrentTenantSerializer(PassiveSerializer): default_locale = CharField(read_only=True) -class TenantViewSet(UsedByMixin, ModelViewSet): - """Tenant Viewset""" +class BrandViewSet(UsedByMixin, ModelViewSet): + """Brand Viewset""" - queryset = Tenant.objects.all() - serializer_class = TenantSerializer + queryset = Brand.objects.all() + serializer_class = BrandSerializer search_fields = [ "domain", "branding_title", "web_certificate__name", ] filterset_fields = [ - "tenant_uuid", + "brand_uuid", "domain", "default", "branding_title", @@ -120,7 +124,6 @@ class TenantViewSet(UsedByMixin, ModelViewSet): "flow_unenrollment", "flow_user_settings", "flow_device_code", - "event_retention", "web_certificate", ] ordering = ["domain"] @@ -128,10 +131,10 @@ class TenantViewSet(UsedByMixin, ModelViewSet): filter_backends = [SecretKeyFilter, OrderingFilter, SearchFilter] @extend_schema( - responses=CurrentTenantSerializer(many=False), + responses=CurrentBrandSerializer(many=False), ) @action(methods=["GET"], detail=False, permission_classes=[AllowAny]) def current(self, request: Request) -> Response: - """Get current tenant""" - tenant: Tenant = request._request.tenant - return Response(CurrentTenantSerializer(tenant).data) + """Get current brand""" + brand: Brand = request._request.brand + return Response(CurrentBrandSerializer(brand).data) diff --git a/authentik/brands/apps.py b/authentik/brands/apps.py new file mode 100644 index 0000000000..a116f7c4bc --- /dev/null +++ b/authentik/brands/apps.py @@ -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" diff --git a/authentik/brands/middleware.py b/authentik/brands/middleware.py new file mode 100644 index 0000000000..744b700c9e --- /dev/null +++ b/authentik/brands/middleware.py @@ -0,0 +1,26 @@ +"""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 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) + locale = brand.default_locale + if locale != "": + activate(locale) + return self.get_response(request) diff --git a/authentik/tenants/migrations/0001_squashed_0005_tenant_web_certificate.py b/authentik/brands/migrations/0001_squashed_0005_tenant_web_certificate.py similarity index 80% rename from authentik/tenants/migrations/0001_squashed_0005_tenant_web_certificate.py rename to authentik/brands/migrations/0001_squashed_0005_tenant_web_certificate.py index 65cfaf01e7..4d0e592f55 100644 --- a/authentik/tenants/migrations/0001_squashed_0005_tenant_web_certificate.py +++ b/authentik/brands/migrations/0001_squashed_0005_tenant_web_certificate.py @@ -10,11 +10,11 @@ import authentik.lib.utils.time class Migration(migrations.Migration): replaces = [ - ("authentik_tenants", "0001_initial"), - ("authentik_tenants", "0002_default"), - ("authentik_tenants", "0003_tenant_branding_favicon"), - ("authentik_tenants", "0004_tenant_event_retention"), - ("authentik_tenants", "0005_tenant_web_certificate"), + ("authentik_brands", "0001_initial"), + ("authentik_brands", "0002_default"), + ("authentik_brands", "0003_tenant_branding_favicon"), + ("authentik_brands", "0004_tenant_event_retention"), + ("authentik_brands", "0005_tenant_web_certificate"), ] initial = True @@ -25,7 +25,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="Tenant", + name="Brand", fields=[ ( "tenant_uuid", @@ -37,7 +37,7 @@ class Migration(migrations.Migration): "domain", models.TextField( help_text=( - "Domain that activates this tenant. Can be a superset, i.e. `a.b` for" + "Domain that activates this brand. Can be a superset, i.e. `a.b` for" " `aa.b` and `ba.b`" ) ), @@ -53,7 +53,7 @@ class Migration(migrations.Migration): models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="tenant_authentication", + related_name="brand_authentication", to="authentik_flows.flow", ), ), @@ -62,7 +62,7 @@ class Migration(migrations.Migration): models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="tenant_invalidation", + related_name="brand_invalidation", to="authentik_flows.flow", ), ), @@ -71,7 +71,7 @@ class Migration(migrations.Migration): models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="tenant_recovery", + related_name="brand_recovery", to="authentik_flows.flow", ), ), @@ -80,23 +80,23 @@ class Migration(migrations.Migration): models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="tenant_unenrollment", + related_name="brand_unenrollment", to="authentik_flows.flow", ), ), ], options={ - "verbose_name": "Tenant", - "verbose_name_plural": "Tenants", + "verbose_name": "Brand", + "verbose_name_plural": "Brands", }, ), migrations.AddField( - model_name="tenant", + model_name="brand", name="branding_favicon", field=models.TextField(default="/static/dist/assets/icons/icon.png"), ), migrations.AddField( - model_name="tenant", + model_name="brand", name="event_retention", field=models.TextField( default="days=365", @@ -108,7 +108,7 @@ class Migration(migrations.Migration): ), ), migrations.AddField( - model_name="tenant", + model_name="brand", name="web_certificate", field=models.ForeignKey( default=None, diff --git a/authentik/tenants/migrations/0002_tenant_flow_user_settings.py b/authentik/brands/migrations/0002_tenant_flow_user_settings.py similarity index 79% rename from authentik/tenants/migrations/0002_tenant_flow_user_settings.py rename to authentik/brands/migrations/0002_tenant_flow_user_settings.py index 2f17db4a13..6c356f2d30 100644 --- a/authentik/tenants/migrations/0002_tenant_flow_user_settings.py +++ b/authentik/brands/migrations/0002_tenant_flow_user_settings.py @@ -8,17 +8,17 @@ class Migration(migrations.Migration): dependencies = [ ("authentik_stages_prompt", "0007_prompt_placeholder_expression"), ("authentik_flows", "0021_auto_20211227_2103"), - ("authentik_tenants", "0001_squashed_0005_tenant_web_certificate"), + ("authentik_brands", "0001_squashed_0005_tenant_web_certificate"), ] operations = [ migrations.AddField( - model_name="tenant", + model_name="brand", name="flow_user_settings", field=models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="tenant_user_settings", + related_name="brand_user_settings", to="authentik_flows.flow", ), ), diff --git a/authentik/tenants/migrations/0003_tenant_attributes.py b/authentik/brands/migrations/0003_tenant_attributes.py similarity index 76% rename from authentik/tenants/migrations/0003_tenant_attributes.py rename to authentik/brands/migrations/0003_tenant_attributes.py index 3431af7f09..221ca9c1c5 100644 --- a/authentik/tenants/migrations/0003_tenant_attributes.py +++ b/authentik/brands/migrations/0003_tenant_attributes.py @@ -5,12 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("authentik_tenants", "0002_tenant_flow_user_settings"), + ("authentik_brands", "0002_tenant_flow_user_settings"), ] operations = [ migrations.AddField( - model_name="tenant", + model_name="brand", name="attributes", field=models.JSONField(blank=True, default=dict), ), diff --git a/authentik/tenants/migrations/0004_tenant_flow_device_code.py b/authentik/brands/migrations/0004_tenant_flow_device_code.py similarity index 79% rename from authentik/tenants/migrations/0004_tenant_flow_device_code.py rename to authentik/brands/migrations/0004_tenant_flow_device_code.py index 2e2fdcf458..50de430d52 100644 --- a/authentik/tenants/migrations/0004_tenant_flow_device_code.py +++ b/authentik/brands/migrations/0004_tenant_flow_device_code.py @@ -7,17 +7,17 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("authentik_flows", "0023_flow_denied_action"), - ("authentik_tenants", "0003_tenant_attributes"), + ("authentik_brands", "0003_tenant_attributes"), ] operations = [ migrations.AddField( - model_name="tenant", + model_name="brand", name="flow_device_code", field=models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="tenant_device_code", + related_name="brand_device_code", to="authentik_flows.flow", ), ), diff --git a/authentik/brands/migrations/0005_tenantuuid_to_branduuid.py b/authentik/brands/migrations/0005_tenantuuid_to_branduuid.py new file mode 100644 index 0000000000..89f777822e --- /dev/null +++ b/authentik/brands/migrations/0005_tenantuuid_to_branduuid.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.7 on 2023-12-12 06:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_brands", "0004_tenant_flow_device_code"), + ] + + operations = [ + migrations.RenameField( + model_name="brand", + old_name="tenant_uuid", + new_name="brand_uuid", + ), + migrations.RemoveField( + model_name="brand", + name="event_retention", + ), + ] diff --git a/authentik/brands/migrations/__init__.py b/authentik/brands/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/brands/models.py b/authentik/brands/models.py new file mode 100644 index 0000000000..268aa5c26a --- /dev/null +++ b/authentik/brands/models.py @@ -0,0 +1,85 @@ +"""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 + +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" + ) + + 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") diff --git a/authentik/brands/tests.py b/authentik/brands/tests.py new file mode 100644 index 0000000000..71f18ca4e9 --- /dev/null +++ b/authentik/brands/tests.py @@ -0,0 +1,76 @@ +"""Test brands""" +from django.urls import reverse +from rest_framework.test import APITestCase + +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 + + +class TestBrands(APITestCase): + """Test brands""" + + def test_current_brand(self): + """Test Current brand API""" + brand = create_test_brand() + self.assertJSONEqual( + self.client.get(reverse("authentik_api:brand-current")).content.decode(), + { + "branding_logo": "/static/dist/assets/icons/icon_left_brand.svg", + "branding_favicon": "/static/dist/assets/icons/icon.png", + "branding_title": "authentik", + "matched_domain": brand.domain, + "ui_footer_links": [], + "ui_theme": Themes.AUTOMATIC, + "default_locale": "", + }, + ) + + def test_brand_subdomain(self): + """Test Current brand API""" + Brand.objects.all().delete() + Brand.objects.create(domain="bar.baz", branding_title="custom") + self.assertJSONEqual( + self.client.get( + reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz" + ).content.decode(), + { + "branding_logo": "/static/dist/assets/icons/icon_left_brand.svg", + "branding_favicon": "/static/dist/assets/icons/icon.png", + "branding_title": "custom", + "matched_domain": "bar.baz", + "ui_footer_links": [], + "ui_theme": Themes.AUTOMATIC, + "default_locale": "", + }, + ) + + def test_fallback(self): + """Test fallback brand""" + Brand.objects.all().delete() + self.assertJSONEqual( + self.client.get(reverse("authentik_api:brand-current")).content.decode(), + { + "branding_logo": "/static/dist/assets/icons/icon_left_brand.svg", + "branding_favicon": "/static/dist/assets/icons/icon.png", + "branding_title": "authentik", + "matched_domain": "fallback", + "ui_footer_links": [], + "ui_theme": Themes.AUTOMATIC, + "default_locale": "", + }, + ) + + def test_create_default_multiple(self): + """Test attempted creation of multiple default brands""" + Brand.objects.create( + domain="foo", + default=True, + branding_title="custom", + ) + user = create_test_admin_user() + self.client.force_login(user) + response = self.client.post( + reverse("authentik_api:brand-list"), data={"domain": "bar", "default": True} + ) + self.assertEqual(response.status_code, 400) diff --git a/authentik/brands/urls.py b/authentik/brands/urls.py new file mode 100644 index 0000000000..b71406d1c9 --- /dev/null +++ b/authentik/brands/urls.py @@ -0,0 +1,6 @@ +"""API URLs""" +from authentik.brands.api import BrandViewSet + +api_urlpatterns = [ + ("core/brands", BrandViewSet), +] diff --git a/authentik/brands/utils.py b/authentik/brands/utils.py new file mode 100644 index 0000000000..ab1778148a --- /dev/null +++ b/authentik/brands/utils.py @@ -0,0 +1,42 @@ +"""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.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(), + } diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 2bb18e0c62..a970e6d0bc 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -50,6 +50,7 @@ from structlog.stdlib import get_logger from authentik.admin.api.metrics import CoordinateSerializer from authentik.api.decorators import permission_required from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT +from authentik.brands.models import Brand from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer from authentik.core.middleware import ( @@ -71,11 +72,9 @@ from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import FlowToken from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from authentik.flows.views.executor import QS_KEY_TOKEN -from authentik.lib.config import CONFIG from authentik.stages.email.models import EmailStage from authentik.stages.email.tasks import send_mails from authentik.stages.email.utils import TemplateEmailMessage -from authentik.tenants.models import Tenant LOGGER = get_logger() @@ -221,7 +220,7 @@ class UserSelfSerializer(ModelSerializer): } def get_settings(self, user: User) -> dict[str, Any]: - """Get user settings with tenant and group settings applied""" + """Get user settings with brand and group settings applied""" return user.group_attributes(self._context["request"]).get("settings", {}) def get_system_permissions(self, user: User) -> list[str]: @@ -382,11 +381,11 @@ class UserViewSet(UsedByMixin, ModelViewSet): return User.objects.all().exclude(pk=get_anonymous_user().pk) def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]: - """Create a recovery link (when the current tenant has a recovery flow set), + """Create a recovery link (when the current brand has a recovery flow set), that can either be shown to an admin or sent to the user directly""" - tenant: Tenant = self.request._request.tenant + brand: Brand = self.request._request.brand # Check that there is a recovery flow, if not return an error - flow = tenant.flow_recovery + flow = brand.flow_recovery if not flow: LOGGER.debug("No recovery flow set") return None, None @@ -618,7 +617,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): @action(detail=True, methods=["POST"]) def impersonate(self, request: Request, pk: int) -> Response: """Impersonate a user""" - if not CONFIG.get_bool("impersonation"): + if not request.tenant.impersonation: LOGGER.debug("User attempted to impersonate", user=request.user) return Response(status=401) if not request.user.has_perm("impersonate"): diff --git a/authentik/core/apps.py b/authentik/core/apps.py index 719b2abb1c..f158cd4a8f 100644 --- a/authentik/core/apps.py +++ b/authentik/core/apps.py @@ -13,18 +13,18 @@ class AuthentikCoreConfig(ManagedAppConfig): mountpoint = "" default = True - def reconcile_load_core_signals(self): + def reconcile_global_load_core_signals(self): """Load core signals""" self.import_module("authentik.core.signals") - def reconcile_debug_worker_hook(self): + def reconcile_global_debug_worker_hook(self): """Dispatch startup tasks inline when debugging""" if settings.DEBUG: from authentik.root.celery import worker_ready_hook worker_ready_hook() - def reconcile_source_inbuilt(self): + def reconcile_tenant_source_inbuilt(self): """Reconcile inbuilt source""" from authentik.core.models import Source diff --git a/authentik/core/management/commands/bootstrap_tasks.py b/authentik/core/management/commands/bootstrap_tasks.py index e57f822587..89258eaf4c 100644 --- a/authentik/core/management/commands/bootstrap_tasks.py +++ b/authentik/core/management/commands/bootstrap_tasks.py @@ -1,13 +1,20 @@ """Run bootstrap tasks""" from django.core.management.base import BaseCommand +from django_tenants.utils import get_public_schema_name -from authentik.root.celery import _get_startup_tasks +from authentik.root.celery import _get_startup_tasks_all_tenants, _get_startup_tasks_default_tenant +from authentik.tenants.models import Tenant class Command(BaseCommand): """Run bootstrap tasks to ensure certain objects are created""" def handle(self, **options): - tasks = _get_startup_tasks() - for task in tasks: - task() + for task in _get_startup_tasks_default_tenant(): + with Tenant.objects.get(schema_name=get_public_schema_name()): + task() + + for task in _get_startup_tasks_all_tenants(): + for tenant in Tenant.objects.filter(ready=True): + with tenant: + task() diff --git a/authentik/core/management/commands/repair_permissions.py b/authentik/core/management/commands/repair_permissions.py index 242aef45a9..25200b2ad0 100644 --- a/authentik/core/management/commands/repair_permissions.py +++ b/authentik/core/management/commands/repair_permissions.py @@ -4,6 +4,8 @@ from django.contrib.auth.management import create_permissions from django.core.management.base import BaseCommand, no_translations from guardian.management import create_anonymous_user +from authentik.tenants.models import Tenant + class Command(BaseCommand): """Repair missing permissions""" @@ -11,7 +13,9 @@ class Command(BaseCommand): @no_translations def handle(self, *args, **options): """Check permissions for all apps""" - for app in apps.get_app_configs(): - self.stdout.write(f"Checking app {app.name} ({app.label})\n") - create_permissions(app, verbosity=0) - create_anonymous_user(None, using="default") + for tenant in Tenant.objects.filter(ready=True): + with tenant: + for app in apps.get_app_configs(): + self.stdout.write(f"Checking app {app.name} ({app.label})\n") + create_permissions(app, verbosity=0) + create_anonymous_user(None, using="default") diff --git a/authentik/core/models.py b/authentik/core/models.py index 125d5b0c85..2352055cc8 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -201,8 +201,8 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): """Get a dictionary containing the attributes from all groups the user belongs to, including the users attributes""" final_attributes = {} - if request and hasattr(request, "tenant"): - always_merger.merge(final_attributes, request.tenant.attributes) + if request and hasattr(request, "brand"): + always_merger.merge(final_attributes, request.brand.attributes) for group in self.all_groups().order_by("name"): always_merger.merge(final_attributes, group.attributes) always_merger.merge(final_attributes, self.attributes) @@ -261,7 +261,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): except Exception as exc: LOGGER.warning("Failed to get default locale", exc=exc) if request: - return request.tenant.locale + return request.brand.locale return "" @property diff --git a/authentik/core/templates/base/header_js.html b/authentik/core/templates/base/header_js.html index 2d15718287..a67a0b3dae 100644 --- a/authentik/core/templates/base/header_js.html +++ b/authentik/core/templates/base/header_js.html @@ -5,7 +5,7 @@ window.authentik = { locale: "{{ LANGUAGE_CODE }}", config: JSON.parse('{{ config_json|escapejs }}'), - tenant: JSON.parse('{{ tenant_json|escapejs }}'), + brand: JSON.parse('{{ brand_json|escapejs }}'), versionFamily: "{{ version_family }}", versionSubdomain: "{{ version_subdomain }}", build: "{{ build }}", diff --git a/authentik/core/templates/base/skeleton.html b/authentik/core/templates/base/skeleton.html index 85137cc428..377485948b 100644 --- a/authentik/core/templates/base/skeleton.html +++ b/authentik/core/templates/base/skeleton.html @@ -7,9 +7,9 @@ - {% block title %}{% trans title|default:tenant.branding_title %}{% endblock %} - - + {% block title %}{% trans title|default:brand.branding_title %}{% endblock %} + + {% block head_before %} {% endblock %} diff --git a/authentik/core/templates/if/end_session.html b/authentik/core/templates/if/end_session.html index e6bfccb1b8..88cb345a14 100644 --- a/authentik/core/templates/if/end_session.html +++ b/authentik/core/templates/if/end_session.html @@ -4,7 +4,7 @@ {% load i18n %} {% block title %} -{% trans 'End session' %} - {{ tenant.branding_title }} +{% trans 'End session' %} - {{ brand.branding_title }} {% endblock %} {% block card_title %} @@ -16,7 +16,7 @@ You've logged out of {{ application }}. {% block card %}

- {% blocktrans with application=application.name branding_title=tenant.branding_title %} + {% blocktrans with application=application.name branding_title=brand.branding_title %} You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account. {% endblocktrans %}

@@ -26,7 +26,7 @@ You've logged out of {{ application }}. - {% blocktrans with branding_title=tenant.branding_title %} + {% blocktrans with branding_title=brand.branding_title %} Log out of {{ branding_title }} {% endblocktrans %} diff --git a/authentik/core/templates/if/error.html b/authentik/core/templates/if/error.html index 9470f02c6c..6abc12e3e9 100644 --- a/authentik/core/templates/if/error.html +++ b/authentik/core/templates/if/error.html @@ -4,7 +4,7 @@ {% load i18n %} {% block title %} -{{ tenant.branding_title }} +{{ brand.branding_title }} {% endblock %} {% block card_title %} diff --git a/authentik/core/templates/login/base_full.html b/authentik/core/templates/login/base_full.html index be6e3a040a..c88692a632 100644 --- a/authentik/core/templates/login/base_full.html +++ b/authentik/core/templates/login/base_full.html @@ -50,7 +50,7 @@
diff --git a/web/src/admin/providers/ldap/LDAPProviderForm.ts b/web/src/admin/providers/ldap/LDAPProviderForm.ts index 4ddd19b2ba..db426cc1a4 100644 --- a/web/src/admin/providers/ldap/LDAPProviderForm.ts +++ b/web/src/admin/providers/ldap/LDAPProviderForm.ts @@ -1,9 +1,9 @@ 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 { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; -import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; +import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/Radio"; @@ -25,7 +25,7 @@ import { } from "@goauthentik/api"; @customElement("ak-provider-ldap-form") -export class LDAPProviderFormPage extends WithTenantConfig(BaseProviderForm) { +export class LDAPProviderFormPage extends WithBrandConfig(BaseProviderForm) { async loadInstance(pk: number): Promise { return new ProvidersApi(DEFAULT_CONFIG).providersLdapRetrieve({ id: pk, @@ -48,7 +48,7 @@ export class LDAPProviderFormPage extends WithTenantConfig(BaseProviderForm - + >

${msg("Flow used for users to authenticate.")}

diff --git a/web/src/admin/providers/rac/RACProviderForm.ts b/web/src/admin/providers/rac/RACProviderForm.ts index 53a5357a93..e4f6b789f3 100644 --- a/web/src/admin/providers/rac/RACProviderForm.ts +++ b/web/src/admin/providers/rac/RACProviderForm.ts @@ -1,5 +1,5 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; -import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; import { first } from "@goauthentik/app/common/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/elements/CodeMirror"; diff --git a/web/src/admin/providers/radius/RadiusProviderForm.ts b/web/src/admin/providers/radius/RadiusProviderForm.ts index f37c865d5d..2d08fc0b60 100644 --- a/web/src/admin/providers/radius/RadiusProviderForm.ts +++ b/web/src/admin/providers/radius/RadiusProviderForm.ts @@ -1,7 +1,7 @@ import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; -import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; +import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/SearchSelect"; @@ -14,7 +14,7 @@ import { customElement } from "lit/decorators.js"; import { FlowsInstancesListDesignationEnum, ProvidersApi, RadiusProvider } from "@goauthentik/api"; @customElement("ak-provider-radius-form") -export class RadiusProviderFormPage extends WithTenantConfig(BaseProviderForm) { +export class RadiusProviderFormPage extends WithBrandConfig(BaseProviderForm) { loadInstance(pk: number): Promise { return new ProvidersApi(DEFAULT_CONFIG).providersRadiusRetrieve({ id: pk, @@ -37,7 +37,7 @@ export class RadiusProviderFormPage extends WithTenantConfig(BaseProviderForm - + >

${msg("Flow used for users to authenticate.")}

diff --git a/web/src/admin/stages/prompt/PromptForm.ts b/web/src/admin/stages/prompt/PromptForm.ts index bddf1c368f..386ae7004f 100644 --- a/web/src/admin/stages/prompt/PromptForm.ts +++ b/web/src/admin/stages/prompt/PromptForm.ts @@ -28,7 +28,7 @@ class PreviewStageHost implements StageHost { challenge = undefined; flowSlug = undefined; loading = false; - tenant = undefined; + brand = undefined; async submit(payload: unknown): Promise { this.promptForm.previewResult = payload; return false; diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index afb88f3f6f..4f68955605 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -12,11 +12,11 @@ import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-status-label"; import { rootInterface } from "@goauthentik/elements/Base"; +import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import { CapabilitiesEnum, WithCapabilitiesConfig, } from "@goauthentik/elements/Interface/capabilitiesProvider"; -import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; import { PFSize } from "@goauthentik/elements/Spinner"; import "@goauthentik/elements/TreeView"; import "@goauthentik/elements/buttons/ActionButton"; @@ -61,7 +61,7 @@ export const requestRecoveryLink = (user: User) => showMessage({ level: MessageLevel.error, message: msg( - "The current tenant must have a recovery flow configured to use a recovery link", + "The current brand must have a recovery flow configured to use a recovery link", ), }), ), @@ -91,7 +91,7 @@ const recoveryButtonStyles = css` `; @customElement("ak-user-list") -export class UserListPage extends WithTenantConfig(WithCapabilitiesConfig(TablePage)) { +export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePage)) { expandable = true; checkbox = true; @@ -352,7 +352,7 @@ export class UserListPage extends WithTenantConfig(WithCapabilitiesConfig(TableP ${msg("Set password")} - ${this.tenant.flowRecovery + ${this.brand.flowRecovery ? html` ${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.", )}

`} diff --git a/web/src/common/api/config.ts b/web/src/common/api/config.ts index 8de76d840d..dd1a2c1b75 100644 --- a/web/src/common/api/config.ts +++ b/web/src/common/api/config.ts @@ -6,7 +6,7 @@ import { import { EVENT_LOCALE_REQUEST, EVENT_REFRESH, VERSION } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; -import { Config, Configuration, CoreApi, CurrentTenant, RootApi } from "@goauthentik/api"; +import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api"; let globalConfigPromise: Promise | undefined = Promise.resolve(globalAK().config); export function config(): Promise { @@ -16,7 +16,7 @@ export function config(): Promise { return globalConfigPromise; } -export function tenantSetFavicon(tenant: CurrentTenant) { +export function brandSetFavicon(brand: CurrentBrand) { /** * * @@ -29,36 +29,36 @@ export function tenantSetFavicon(tenant: CurrentTenant) { relIcon.rel = rel; document.getElementsByTagName("head")[0].appendChild(relIcon); } - relIcon.href = tenant.brandingFavicon; + relIcon.href = brand.brandingFavicon; }); } -export function tenantSetLocale(tenant: CurrentTenant) { - if (tenant.defaultLocale === "") { +export function brandSetLocale(brand: CurrentBrand) { + if (brand.defaultLocale === "") { return; } - console.debug("authentik/locale: setting locale from tenant default"); + console.debug("authentik/locale: setting locale from brand default"); window.dispatchEvent( new CustomEvent(EVENT_LOCALE_REQUEST, { composed: true, bubbles: true, - detail: { locale: tenant.defaultLocale }, + detail: { locale: brand.defaultLocale }, }), ); } -let globalTenantPromise: Promise | undefined = Promise.resolve(globalAK().tenant); -export function tenant(): Promise { - if (!globalTenantPromise) { - globalTenantPromise = new CoreApi(DEFAULT_CONFIG) - .coreTenantsCurrentRetrieve() - .then((tenant) => { - tenantSetFavicon(tenant); - tenantSetLocale(tenant); - return tenant; +let globalBrandPromise: Promise | undefined = Promise.resolve(globalAK().brand); +export function brand(): Promise { + if (!globalBrandPromise) { + globalBrandPromise = new CoreApi(DEFAULT_CONFIG) + .coreBrandsCurrentRetrieve() + .then((brand) => { + brandSetFavicon(brand); + brandSetLocale(brand); + return brand; }); } - return globalTenantPromise; + return globalBrandPromise; } export function getMetaContent(key: string): string { @@ -75,7 +75,7 @@ export const DEFAULT_CONFIG = new Configuration({ middleware: [ new CSRFMiddleware(), new EventMiddleware(), - new LoggingMiddleware(globalAK().tenant), + new LoggingMiddleware(globalAK().brand), ], }); @@ -90,9 +90,9 @@ window.addEventListener(EVENT_REFRESH, () => { // Upon global refresh, disregard whatever was pre-hydrated and // actually load info from API globalConfigPromise = undefined; - globalTenantPromise = undefined; + globalBrandPromise = undefined; config(); - tenant(); + brand(); }); console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`); diff --git a/web/src/common/api/middleware.ts b/web/src/common/api/middleware.ts index 745a5d1c92..8aee822c7a 100644 --- a/web/src/common/api/middleware.ts +++ b/web/src/common/api/middleware.ts @@ -2,7 +2,7 @@ import { EVENT_REQUEST_POST } from "@goauthentik/common/constants"; import { getCookie } from "@goauthentik/common/utils"; import { - CurrentTenant, + CurrentBrand, FetchParams, Middleware, RequestContext, @@ -18,13 +18,13 @@ export interface RequestInfo { } export class LoggingMiddleware implements Middleware { - tenant: CurrentTenant; - constructor(tenant: CurrentTenant) { - this.tenant = tenant; + brand: CurrentBrand; + constructor(brand: CurrentBrand) { + this.brand = brand; } post(context: ResponseContext): Promise { - let msg = `authentik/api[${this.tenant.matchedDomain}]: `; + let msg = `authentik/api[${this.brand.matchedDomain}]: `; // https://developer.mozilla.org/en-US/docs/Web/API/console#styling_console_output msg += `%c${context.response.status}%c ${context.init.method} ${context.url}`; let style = ""; diff --git a/web/src/common/global.ts b/web/src/common/global.ts index d9a27e7f09..990303df0d 100644 --- a/web/src/common/global.ts +++ b/web/src/common/global.ts @@ -1,4 +1,4 @@ -import { Config, ConfigFromJSON, CurrentTenant, CurrentTenantFromJSON } from "@goauthentik/api"; +import { Config, ConfigFromJSON, CurrentBrand, CurrentBrandFromJSON } from "@goauthentik/api"; export interface GlobalAuthentik { _converted?: boolean; @@ -7,7 +7,7 @@ export interface GlobalAuthentik { layout: string; }; config: Config; - tenant: CurrentTenant; + brand: CurrentBrand; versionFamily: string; versionSubdomain: string; build: string; @@ -21,7 +21,7 @@ export function globalAK(): GlobalAuthentik { const ak = (window as unknown as AuthentikWindow).authentik; if (ak && !ak._converted) { ak._converted = true; - ak.tenant = CurrentTenantFromJSON(ak.tenant); + ak.brand = CurrentBrandFromJSON(ak.brand); ak.config = ConfigFromJSON(ak.config); } if (!ak) { @@ -29,7 +29,7 @@ export function globalAK(): GlobalAuthentik { config: ConfigFromJSON({ capabilities: [], }), - tenant: CurrentTenantFromJSON({ + brand: CurrentBrandFromJSON({ ui_footer_links: [], }), versionFamily: "", diff --git a/web/src/elements/AuthentikContexts.ts b/web/src/elements/AuthentikContexts.ts index 02fa893169..3a0f1dd1b8 100644 --- a/web/src/elements/AuthentikContexts.ts +++ b/web/src/elements/AuthentikContexts.ts @@ -1,11 +1,9 @@ import { createContext } from "@lit-labs/context"; -import type { Config, CurrentTenant } from "@goauthentik/api"; +import type { Config, CurrentBrand } from "@goauthentik/api"; export const authentikConfigContext = createContext(Symbol("authentik-config-context")); -export const authentikTenantContext = createContext( - Symbol("authentik-tenant-context"), -); +export const authentikBrandContext = createContext(Symbol("authentik-brand-context")); export default authentikConfigContext; diff --git a/web/src/elements/Base.ts b/web/src/elements/Base.ts index 09a2d28580..3e251ce124 100644 --- a/web/src/elements/Base.ts +++ b/web/src/elements/Base.ts @@ -9,13 +9,13 @@ import { LitElement } from "lit"; import AKGlobal from "@goauthentik/common/styles/authentik.css"; import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; -import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api"; +import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api"; import { AdoptedStyleSheetsElement } from "./types"; type AkInterface = HTMLElement & { getTheme: () => Promise; - tenant?: CurrentTenant; + brand?: CurrentBrand; uiConfig?: UIConfig; config?: Config; }; diff --git a/web/src/elements/Interface/Interface.ts b/web/src/elements/Interface/Interface.ts index b2470cfd24..ed4e57c9ae 100644 --- a/web/src/elements/Interface/Interface.ts +++ b/web/src/elements/Interface/Interface.ts @@ -1,8 +1,8 @@ -import { config, tenant } from "@goauthentik/common/api/config"; +import { brand, config } from "@goauthentik/common/api/config"; import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; import { + authentikBrandContext, authentikConfigContext, - authentikTenantContext, } from "@goauthentik/elements/AuthentikContexts"; import type { AdoptedStyleSheetsElement } from "@goauthentik/elements/types"; import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; @@ -12,13 +12,13 @@ import { state } from "lit/decorators.js"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api"; +import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api"; import { AKElement } from "../Base"; type AkInterface = HTMLElement & { getTheme: () => Promise; - tenant?: CurrentTenant; + brand?: CurrentBrand; uiConfig?: UIConfig; config?: Config; }; @@ -45,28 +45,28 @@ export class Interface extends AKElement implements AkInterface { return this._config; } - _tenantContext = new ContextProvider(this, { - context: authentikTenantContext, + _brandContext = new ContextProvider(this, { + context: authentikBrandContext, initialValue: undefined, }); - _tenant?: CurrentTenant; + _brand?: CurrentBrand; @state() - set tenant(c: CurrentTenant) { - this._tenant = c; - this._tenantContext.setValue(c); + set brand(c: CurrentBrand) { + this._brand = c; + this._brandContext.setValue(c); this.requestUpdate(); } - get tenant(): CurrentTenant | undefined { - return this._tenant; + get brand(): CurrentBrand | undefined { + return this._brand; } constructor() { super(); document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; - tenant().then((tenant) => (this.tenant = tenant)); + brand().then((brand) => (this.brand = brand)); config().then((config) => (this.config = config)); this.dataset.akInterfaceRoot = "true"; } diff --git a/web/src/elements/Interface/brandProvider.ts b/web/src/elements/Interface/brandProvider.ts new file mode 100644 index 0000000000..242764bf78 --- /dev/null +++ b/web/src/elements/Interface/brandProvider.ts @@ -0,0 +1,20 @@ +import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; + +import { consume } from "@lit-labs/context"; +import type { LitElement } from "lit"; + +import type { CurrentBrand } from "@goauthentik/api"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = abstract new (...args: any[]) => T; + +export function WithBrandConfig>( + superclass: T, + subscribe = true, +) { + abstract class WithBrandProvider extends superclass { + @consume({ context: authentikBrandContext, subscribe }) + public brand!: CurrentBrand; + } + return WithBrandProvider; +} diff --git a/web/src/elements/Interface/tenantProvider.ts b/web/src/elements/Interface/tenantProvider.ts deleted file mode 100644 index 63d3890483..0000000000 --- a/web/src/elements/Interface/tenantProvider.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { authentikTenantContext } from "@goauthentik/elements/AuthentikContexts"; - -import { consume } from "@lit-labs/context"; -import type { LitElement } from "lit"; - -import type { CurrentTenant } from "@goauthentik/api"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Constructor = abstract new (...args: any[]) => T; - -export function WithTenantConfig>( - superclass: T, - subscribe = true, -) { - abstract class WithTenantProvider extends superclass { - @consume({ context: authentikTenantContext, subscribe }) - public tenant!: CurrentTenant; - } - return WithTenantProvider; -} diff --git a/web/src/elements/PageHeader.ts b/web/src/elements/PageHeader.ts index 7be55996d3..d7d7df9f0e 100644 --- a/web/src/elements/PageHeader.ts +++ b/web/src/elements/PageHeader.ts @@ -9,7 +9,7 @@ import { import { currentInterface } from "@goauthentik/common/sentry"; import { me } from "@goauthentik/common/users"; import { AKElement } from "@goauthentik/elements/Base"; -import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; +import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; @@ -24,7 +24,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { EventsApi } from "@goauthentik/api"; @customElement("ak-page-header") -export class PageHeader extends WithTenantConfig(AKElement) { +export class PageHeader extends WithBrandConfig(AKElement) { @property() icon?: string; @@ -37,7 +37,7 @@ export class PageHeader extends WithTenantConfig(AKElement) { @property() set header(value: string) { const currentIf = currentInterface(); - let title = this.tenant?.brandingTitle || TITLE_DEFAULT; + let title = this.brand?.brandingTitle || TITLE_DEFAULT; if (currentIf === "admin") { title = `${msg("Admin")} - ${title}`; } diff --git a/web/src/elements/sidebar/SidebarBrand.ts b/web/src/elements/sidebar/SidebarBrand.ts index 7f4f1754cd..0d818b568f 100644 --- a/web/src/elements/sidebar/SidebarBrand.ts +++ b/web/src/elements/sidebar/SidebarBrand.ts @@ -1,6 +1,6 @@ import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; import { AKElement } from "@goauthentik/elements/Base"; -import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; +import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import { CSSResult, TemplateResult, css, html } from "lit"; import { customElement } from "lit/decorators.js"; @@ -10,13 +10,13 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGlobal from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { CurrentTenant, UiThemeEnum } from "@goauthentik/api"; +import { CurrentBrand, UiThemeEnum } from "@goauthentik/api"; // If the viewport is wider than MIN_WIDTH, the sidebar // is shown besides the content, and not overlaid. export const MIN_WIDTH = 1200; -export const DefaultTenant: CurrentTenant = { +export const DefaultBrand: CurrentBrand = { brandingLogo: "/static/dist/assets/icons/icon_left_brand.svg", brandingFavicon: "/static/dist/assets/icons/icon.png", brandingTitle: "authentik", @@ -27,7 +27,7 @@ export const DefaultTenant: CurrentTenant = { }; @customElement("ak-sidebar-brand") -export class SidebarBrand extends WithTenantConfig(AKElement) { +export class SidebarBrand extends WithBrandConfig(AKElement) { static get styles(): CSSResult[] { return [ PFBase, @@ -84,7 +84,7 @@ export class SidebarBrand extends WithTenantConfig(AKElement) {
authentik Logo diff --git a/web/src/enterprise/rac/index.ts b/web/src/enterprise/rac/index.ts index bf09917d4b..87163cc46a 100644 --- a/web/src/enterprise/rac/index.ts +++ b/web/src/enterprise/rac/index.ts @@ -209,7 +209,7 @@ export class RacInterface extends Interface { } updateTitle(): void { - let title = this.tenant?.brandingTitle || TITLE_DEFAULT; + let title = this.brand?.brandingTitle || TITLE_DEFAULT; if (this.endpointName) { title = `${this.endpointName} - ${title}`; } diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 2303508010..6d7a02b4f7 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -55,9 +55,9 @@ export class FlowExecutor extends Interface implements StageHost { set challenge(value: ChallengeTypes | undefined) { this._challenge = value; if (value?.flowInfo?.title) { - document.title = `${value.flowInfo?.title} - ${this.tenant?.brandingTitle}`; + document.title = `${value.flowInfo?.title} - ${this.brand?.brandingTitle}`; } else { - document.title = this.tenant?.brandingTitle || TITLE_DEFAULT; + document.title = this.brand?.brandingTitle || TITLE_DEFAULT; } this.requestUpdate(); } @@ -186,7 +186,7 @@ export class FlowExecutor extends Interface implements StageHost { } async getTheme(): Promise { - return globalAK()?.tenant.uiTheme || UiThemeEnum.Automatic; + return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; } async submit(payload?: FlowChallengeResponseRequest): Promise { @@ -430,7 +430,7 @@ export class FlowExecutor extends Interface implements StageHost { renderChallengeWrapper(): TemplateResult { const logo = html``; if (!this.challenge) { return html`${logo} @@ -488,7 +488,7 @@ export class FlowExecutor extends Interface implements StageHost {