From abc0c2d2a2a0bfb0214798ed6bca9d59359b39f8 Mon Sep 17 00:00:00 2001
From: Marc 'risson' Schmitt
Date: Tue, 23 Jan 2024 14:28:06 +0100
Subject: [PATCH] root: Multi-tenancy (#7590)
* tenants -> brands, init new tenant model, migrate some config to tenants
Signed-off-by: Marc 'risson' Schmitt
* setup logging for tenants
Signed-off-by: Marc 'risson' Schmitt
* configure celery and cache
Signed-off-by: Marc 'risson' Schmitt
* small fixes, runs
Signed-off-by: Marc 'risson' Schmitt
* task fixes, creation of tenant now works by cloning a template schema, some other small stuff
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* fix-tests
Signed-off-by: Marc 'risson' Schmitt
* upstream fixes
Signed-off-by: Marc 'risson' Schmitt
* fix-pylint
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* fix tests
Signed-off-by: Marc 'risson' Schmitt
* fix avatar tests
Signed-off-by: Marc 'risson' Schmitt
* migrate config reputation_expiry as well
Signed-off-by: Marc 'risson' Schmitt
* fix web rebase
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* fix migrations for template schema
Signed-off-by: Marc 'risson' Schmitt
* fix migrations for template schema
Signed-off-by: Marc 'risson' Schmitt
* fix migrations for template schema 3
Signed-off-by: Marc 'risson' Schmitt
* revert reputation expiry migration
Signed-off-by: Marc 'risson' Schmitt
* fix type
Signed-off-by: Marc 'risson' Schmitt
* fix some more tests
Signed-off-by: Marc 'risson' Schmitt
* website: tenants -> brands
Signed-off-by: Marc 'risson' Schmitt
* try fixing e2e tests
Signed-off-by: Marc 'risson' Schmitt
* start frontend :help:
Signed-off-by: Marc 'risson' Schmitt
* add ability to disable tenants api
Signed-off-by: Marc 'risson' Schmitt
* delete embedded outpost if it is disabled
Signed-off-by: Marc 'risson' Schmitt
* make sure embedded outpost is disabled when tenants are enabled
Signed-off-by: Marc 'risson' Schmitt
* management commands: add --schema option where relevant
Signed-off-by: Marc 'risson' Schmitt
* store files per-tenant
Signed-off-by: Marc 'risson' Schmitt
* fix embedded outpost deletion
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* fix files migration
Signed-off-by: Marc 'risson' Schmitt
* add tenant api tests
Signed-off-by: Marc 'risson' Schmitt
* add domain tests
Signed-off-by: Marc 'risson' Schmitt
* add settings tests
Signed-off-by: Marc 'risson' Schmitt
* make --schema-name default to public in mgmt commands
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* sources/ldap: make sure lock is per-tenant
Signed-off-by: Marc 'risson' Schmitt
* fix stuff I broke
Signed-off-by: Marc 'risson' Schmitt
* fix remaining failing tests
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* try fixing e2e tests
Signed-off-by: Marc 'risson' Schmitt
* much better frontend, but save does not refresh form properly
Signed-off-by: Marc 'risson' Schmitt
* update django-tenants with latest fixes
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* i18n-extract
Signed-off-by: Marc 'risson' Schmitt
* review comments
Signed-off-by: Marc 'risson' Schmitt
* move event_retention from brands to tenants
Signed-off-by: Marc 'risson' Schmitt
* wip
Signed-off-by: Marc 'risson' Schmitt
* root: add support for storing media files in S3
Signed-off-by: Marc 'risson' Schmitt
* use permissions for settings api
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* blueprints: disable tenants management
Signed-off-by: Marc 'risson' Schmitt
* fix tests
Signed-off-by: Marc 'risson' Schmitt
* fix embedded outpost create/delete logic
Signed-off-by: Marc 'risson' Schmitt
* make gen
Signed-off-by: Marc 'risson' Schmitt
* make sure prometheus metrics are correctly served
Signed-off-by: Marc 'risson' Schmitt
* makefile: don't delete the go api client when not regenerating it
Signed-off-by: Marc 'risson' Schmitt
* tenants api: add recovery group and token creation endpoints
Signed-off-by: Marc 'risson' Schmitt
* fix startup
Signed-off-by: Marc 'risson' Schmitt
* fix prometheus metrics
Signed-off-by: Marc 'risson' Schmitt
* fix tests
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* fix web stuff
Signed-off-by: Jens Langhammer
* fix migrations from stable
Signed-off-by: Marc 'risson' Schmitt
* fix oauth source type import
Signed-off-by: Jens Langhammer
* Revert "fix oauth source type import"
This reverts commit d015fd0244a52a5acf6db319d79c2e3a302d7ec0.
* try with setting_changed signal
Signed-off-by: Marc 'risson' Schmitt
* try with connection_created signal
Signed-off-by: Marc 'risson' Schmitt
* fix scim tests
Signed-off-by: Marc 'risson' Schmitt
* fix web after merge
Signed-off-by: Marc 'risson' Schmitt
* fix enterprise settings
Signed-off-by: Marc 'risson' Schmitt
* Revert "try with connection_created signal"
This reverts commit 764a999db8c6c59ad38b0b66c197188c7d47936d.
* Revert "try with setting_changed signal"
This reverts commit 32b40a3bbb4ee01f87a9cf0fcac6f58ba55967a2.
* lib/expression: refactor expression compilation
Signed-off-by: Marc 'risson' Schmitt
* fix django version
Signed-off-by: Marc 'risson' Schmitt
* fix web after merge
Signed-off-by: Marc 'risson' Schmitt
* relock poetry
Signed-off-by: Marc 'risson' Schmitt
* fix reconcile
Signed-off-by: Marc 'risson' Schmitt
* try running tenant save in a transaction
Signed-off-by: Marc 'risson' Schmitt
* black
Signed-off-by: Marc 'risson' Schmitt
* test: export postgres logs for debugging and use failfast
Signed-off-by: Jens Langhammer
* test: fix container name for logs
Signed-off-by: Jens Langhammer
* do not copy tenant data
Signed-off-by: Marc 'risson' Schmitt
* Revert "try running tenant save in a transaction"
This reverts commit da6dec5a610620b1b3e1398cfb957bbae710aae1.
* Revert "do not copy tenant data"
This reverts commit d07ae9423672f068b0bd8be409ff9b58452a80f2.
* Revert "Revert "do not copy tenant data""
This reverts commit 4bffb197047159308a129edfca29944359681428.
* fix clone with nodata
Signed-off-by: Marc 'risson' Schmitt
* why not
Signed-off-by: Marc 'risson' Schmitt
* remove failfast
Signed-off-by: Jens Langhammer
* remove postgres query logging
Signed-off-by: Jens Langhammer
* update reconcile logic to clearly differentiate between tenant and global
Signed-off-by: Marc 'risson' Schmitt
* fix
Signed-off-by: Jens Langhammer
* fix reconcile app decorator
Signed-off-by: Marc 'risson' Schmitt
* enable django checks
Signed-off-by: Marc 'risson' Schmitt
* actually nodata was unnecessary as we're cloning from template and not from public
Signed-off-by: Marc 'risson' Schmitt
* pylint
Signed-off-by: Marc 'risson' Schmitt
* update django-tenants with sequence fix
Signed-off-by: Marc 'risson' Schmitt
* actually update
Signed-off-by: Marc 'risson' Schmitt
* fix e2e tests
Signed-off-by: Marc 'risson' Schmitt
* add tests for settings api
Signed-off-by: Marc 'risson' Schmitt
* add tests for recovery api
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* recovery tests: do them on a new tenant
Signed-off-by: Marc 'risson' Schmitt
* web: fix system status being degraded when embedded outpost is disabled
Signed-off-by: Marc 'risson' Schmitt
* fix recovery tests
Signed-off-by: Marc 'risson' Schmitt
* fix tenants tests
Signed-off-by: Marc 'risson' Schmitt
* lint-fix
Signed-off-by: Marc 'risson' Schmitt
* lint-fix
Signed-off-by: Marc 'risson' Schmitt
* update UI
Signed-off-by: Jens Langhammer
* add management command to create a tenant
Signed-off-by: Jens Langhammer
* add docs
Signed-off-by: Marc 'risson' Schmitt
* release notes
Signed-off-by: Marc 'risson' Schmitt
* more docs
Signed-off-by: Marc 'risson' Schmitt
* checklist
Signed-off-by: Marc 'risson' Schmitt
* self review
Signed-off-by: Marc 'risson' Schmitt
* spelling
Signed-off-by: Marc 'risson' Schmitt
* make web after upgrading
Signed-off-by: Marc 'risson' Schmitt
* remove extra xlif file
Signed-off-by: Marc 'risson' Schmitt
* prettier
Signed-off-by: Marc 'risson' Schmitt
* Revert "add management command to create a tenant"
This reverts commit 39d13c0447b7dc1e4b8f960fb7cdf0797b4445fc.
* split api into smaller files, only import urls when tenants is enabled
Signed-off-by: Jens Langhammer
* rewite some things on the release notes
Signed-off-by: Jens Langhammer
* root: make sure install_id comes from public schema
Signed-off-by: Marc 'risson' Schmitt
* require a license to use tenants
Signed-off-by: Marc 'risson' Schmitt
* lint
Signed-off-by: Marc 'risson' Schmitt
* fix tenants tests
Signed-off-by: Marc 'risson' Schmitt
* fix files migration
Signed-off-by: Marc 'risson' Schmitt
* release notes: add warning about user sessions being invalidated
Signed-off-by: Marc 'risson' Schmitt
* remove api disabled test, we can't test for it
Signed-off-by: Marc 'risson' Schmitt
---------
Signed-off-by: Marc 'risson' Schmitt
Signed-off-by: Jens Langhammer
Co-authored-by: Jens Langhammer
---
Makefile | 24 +-
authentik/admin/api/system.py | 14 +-
authentik/admin/apps.py | 2 +-
authentik/api/templates/api/browser.html | 2 +-
authentik/api/v3/config.py | 2 +-
authentik/blueprints/apps.py | 37 +-
.../management/commands/apply_blueprint.py | 19 +-
.../management/commands/export_blueprint.py | 7 +-
.../blueprints/migrations/0001_initial.py | 11 +-
authentik/blueprints/tests/__init__.py | 2 +-
authentik/blueprints/tests/test_packaged.py | 6 +-
authentik/blueprints/v1/importer.py | 2 +
authentik/blueprints/v1/tasks.py | 20 +-
authentik/brands/__init__.py | 0
authentik/{tenants => brands}/api.py | 53 +-
authentik/brands/apps.py | 10 +
authentik/brands/middleware.py | 26 +
...01_squashed_0005_tenant_web_certificate.py | 32 +-
.../0002_tenant_flow_user_settings.py | 6 +-
.../migrations/0003_tenant_attributes.py | 4 +-
.../0004_tenant_flow_device_code.py | 6 +-
.../0005_tenantuuid_to_branduuid.py | 21 +
authentik/brands/migrations/__init__.py | 0
authentik/brands/models.py | 85 +
authentik/brands/tests.py | 76 +
authentik/brands/urls.py | 6 +
authentik/brands/utils.py | 42 +
authentik/core/api/users.py | 13 +-
authentik/core/apps.py | 6 +-
.../management/commands/bootstrap_tasks.py | 15 +-
.../management/commands/repair_permissions.py | 12 +-
authentik/core/models.py | 6 +-
authentik/core/templates/base/header_js.html | 2 +-
authentik/core/templates/base/skeleton.html | 6 +-
authentik/core/templates/if/end_session.html | 6 +-
authentik/core/templates/if/error.html | 2 +-
authentik/core/templates/login/base_full.html | 2 +-
.../core/tests/test_applications_views.py | 16 +-
authentik/core/tests/test_impersonation.py | 6 +-
authentik/core/tests/test_users_api.py | 22 +-
authentik/core/tests/test_users_avatars.py | 18 +-
authentik/core/tests/utils.py | 10 +-
authentik/core/views/interface.py | 4 +-
authentik/crypto/apps.py | 6 +-
.../management/commands/import_certificate.py | 7 +-
authentik/enterprise/apps.py | 17 +-
authentik/enterprise/providers/rac/apps.py | 2 +-
authentik/enterprise/settings.py | 2 +-
authentik/events/api/events.py | 8 +-
authentik/events/apps.py | 6 +-
...alter_notificationtransport_webhook_url.py | 2 +-
.../0003_rename_tenant_event_brand.py | 17 +
authentik/events/models.py | 17 +-
authentik/events/monitored_tasks.py | 6 +-
authentik/events/signals.py | 4 +-
authentik/events/tests/test_event.py | 22 +-
authentik/events/utils.py | 7 +-
authentik/flows/apps.py | 5 +-
authentik/flows/signals.py | 5 +-
authentik/flows/views/executor.py | 10 +-
authentik/lib/avatars.py | 5 +-
authentik/lib/config.py | 8 +-
authentik/lib/default.yml | 41 +-
authentik/lib/expression/evaluator.py | 19 +-
authentik/lib/logging.py | 14 +
authentik/outposts/apps.py | 48 +-
authentik/outposts/consumer.py | 4 +
authentik/outposts/models.py | 8 +-
authentik/outposts/signals.py | 4 +-
authentik/policies/apps.py | 3 +-
...ed_0018_alter_eventmatcherpolicy_action.py | 2 +-
.../0019_alter_eventmatcherpolicy_app.py | 2 +-
authentik/policies/password/models.py | 2 +-
authentik/policies/reputation/apps.py | 4 +-
authentik/policies/signals.py | 5 +-
.../policies/templates/policies/denied.html | 2 +-
.../oauth2/tests/test_device_init.py | 12 +-
.../providers/oauth2/views/device_init.py | 8 +-
authentik/providers/oauth2/views/github.py | 4 +-
authentik/providers/proxy/apps.py | 2 +-
authentik/providers/scim/apps.py | 2 +-
.../scim/management/commands/scim_sync.py | 6 +-
.../providers/scim/tests/test_membership.py | 2 +
authentik/providers/scim/tests/test_user.py | 2 +
authentik/rbac/apps.py | 2 +-
.../0003_alter_systempermission_options.py | 29 +
authentik/rbac/models.py | 2 +
authentik/recovery/lib.py | 35 +
.../management/commands/create_admin_group.py | 19 +-
.../commands/create_recovery_key.py | 33 +-
authentik/recovery/tests.py | 25 +-
authentik/root/celery.py | 29 +-
authentik/root/db/base.py | 3 +-
authentik/root/install_id.py | 4 +-
authentik/root/settings.py | 106 +-
authentik/root/storages.py | 116 +
authentik/root/test_runner.py | 3 +-
authentik/sources/ldap/apps.py | 2 +-
.../commands/ldap_check_connection.py | 6 +-
.../ldap/management/commands/ldap_sync.py | 6 +-
authentik/sources/ldap/models.py | 30 +-
authentik/sources/oauth/apps.py | 2 +-
authentik/sources/saml/apps.py | 2 +-
authentik/stages/authenticator_duo/apps.py | 2 +-
authentik/stages/authenticator_static/apps.py | 2 +-
authentik/stages/authenticator_totp/stage.py | 2 +-
.../authenticator_validate/challenge.py | 2 +-
.../authenticator_validate/tests/test_duo.py | 4 +-
.../stages/authenticator_webauthn/stage.py | 2 +-
authentik/stages/email/apps.py | 2 +-
.../email/management/commands/test_email.py | 9 +-
authentik/tenants/api/__init__.py | 0
authentik/tenants/api/domains.py | 40 +
authentik/tenants/api/settings.py | 52 +
authentik/tenants/api/tenants.py | 149 ++
authentik/tenants/apps.py | 35 +-
authentik/tenants/checks.py | 20 +
authentik/tenants/management/__init__.py | 43 +
authentik/tenants/middleware.py | 29 -
authentik/tenants/migrations/0001_initial.py | 150 ++
authentik/tenants/models.py | 153 +-
authentik/tenants/scheduler.py | 12 +
authentik/tenants/tests.py | 102 -
authentik/tenants/tests/__init__.py | 0
authentik/tenants/tests/test_api.py | 66 +
authentik/tenants/tests/test_domain.py | 51 +
.../tenants/tests/test_event_retention.py | 30 +
authentik/tenants/tests/test_recovery.py | 89 +
authentik/tenants/tests/test_settings.py | 67 +
authentik/tenants/tests/utils.py | 46 +
authentik/tenants/urls.py | 16 +-
authentik/tenants/utils.py | 43 +-
blueprints/default/default-brand.yaml | 31 +
blueprints/default/default-tenant.yaml | 28 -
.../flow-default-user-settings-flow.yaml | 281 ++-
blueprints/schema.json | 87 +-
cmd/server/server.go | 8 +-
internal/config/struct.go | 15 +-
internal/outpost/ldap/refresh.go | 4 +-
.../tenant_tls.go => brand_tls/brand_tls.go} | 23 +-
internal/web/static.go | 9 +-
internal/web/tls.go | 5 +-
internal/web/web.go | 5 +-
lifecycle/ak | 6 +
lifecycle/migrate.py | 3 +-
lifecycle/system_migrations/tenant_files.py | 18 +
.../system_migrations/tenant_to_brand.py | 25 +
locale/en/LC_MESSAGES/django.po | 142 +-
poetry.lock | 378 +++-
pyproject.toml | 7 +-
schema.yml | 1894 ++++++++++++-----
scripts/create_bucket.sh | 16 +
scripts/docker-compose.yml | 17 +
scripts/generate_config.py | 18 +
tests/e2e/test_provider_ldap.py | 3 +
tests/e2e/utils.py | 2 +
.../wdio/test/pageobjects/forms/ldap.form.ts | 2 +-
.../test/pageobjects/forms/radius.form.ts | 2 +-
web/src/admin/AdminInterface/AdminSidebar.ts | 5 +-
web/src/admin/Routes.ts | 10 +-
.../admin-overview/cards/RecentEventsCard.ts | 4 +-
.../admin-overview/cards/SystemStatusCard.ts | 7 +-
.../admin/admin-settings/AdminSettingsForm.ts | 187 ++
.../admin/admin-settings/AdminSettingsPage.ts | 115 +
...plication-wizard-authentication-by-ldap.ts | 12 +-
...lication-wizard-authentication-by-oauth.ts | 2 +-
...ication-wizard-authentication-by-radius.ts | 12 +-
...rd-authentication-by-saml-configuration.ts | 2 +-
...plication-wizard-authentication-by-scim.ts | 2 +-
.../TenantForm.ts => brands/BrandForm.ts} | 73 +-
.../BrandListPage.ts} | 46 +-
.../admin/common/ak-flow-search/FlowSearch.ts | 4 +-
.../ak-flow-search/ak-branded-flow-search.ts | 34 +
.../ak-flow-search/ak-tenanted-flow-search.ts | 34 -
web/src/admin/events/EventListPage.ts | 4 +-
web/src/admin/events/EventViewPage.ts | 4 +-
web/src/admin/groups/RelatedUserList.ts | 8 +-
.../admin/providers/ldap/LDAPProviderForm.ts | 14 +-
.../admin/providers/rac/RACProviderForm.ts | 2 +-
.../providers/radius/RadiusProviderForm.ts | 12 +-
web/src/admin/stages/prompt/PromptForm.ts | 2 +-
web/src/admin/users/UserListPage.ts | 10 +-
web/src/common/api/config.ts | 40 +-
web/src/common/api/middleware.ts | 10 +-
web/src/common/global.ts | 8 +-
web/src/elements/AuthentikContexts.ts | 6 +-
web/src/elements/Base.ts | 4 +-
web/src/elements/Interface/Interface.ts | 26 +-
web/src/elements/Interface/brandProvider.ts | 20 +
web/src/elements/Interface/tenantProvider.ts | 20 -
web/src/elements/PageHeader.ts | 6 +-
web/src/elements/sidebar/SidebarBrand.ts | 10 +-
web/src/enterprise/rac/index.ts | 2 +-
web/src/flow/FlowExecutor.ts | 10 +-
.../AuthenticatorValidateStage.ts | 6 +-
web/src/flow/stages/base.ts | 4 +-
web/src/standalone/api-browser/index.ts | 6 +-
web/src/standalone/loading/index.ts | 2 +-
web/src/user/UserInterface.ts | 9 +-
.../details/UserSettingsFlowExecutor.ts | 6 +-
.../details/stages/prompt/PromptStage.ts | 4 +-
web/xliff/de.xlf | 162 +-
web/xliff/en.xlf | 163 +-
web/xliff/es.xlf | 162 +-
web/xliff/fr.xlf | 175 +-
web/xliff/ko.xlf | 175 +-
web/xliff/nl.xlf | 174 +-
web/xliff/pl.xlf | 163 +-
web/xliff/pseudo-LOCALE.xlf | 175 +-
web/xliff/tr.xlf | 162 +-
web/xliff/zh-CN.xlf | 152 +-
web/xliff/zh-Hans.xlf | 219 +-
web/xliff/zh-Hant.xlf | 162 +-
web/xliff/zh_TW.xlf | 175 +-
website/docs/advanced/tenancy.md | 41 +
website/docs/core/{tenants.md => brands.md} | 12 +-
website/docs/core/certificates.md | 2 +-
website/docs/events/index.md | 42 +-
website/docs/flow/executors/user-settings.md | 2 +-
.../flow/stages/authenticator_totp/index.md | 2 +-
website/docs/installation/configuration.mdx | 16 +
website/docs/installation/storage-s3.md | 104 +
.../docs/interfaces/flow/customization.mdx | 2 +-
website/docs/providers/oauth2/device_code.md | 2 +-
website/docs/releases/2024/v2024.1.md | 55 +-
website/docs/troubleshooting/emails.md | 4 +-
website/sidebars.js | 14 +-
227 files changed, 6554 insertions(+), 2481 deletions(-)
create mode 100644 authentik/brands/__init__.py
rename authentik/{tenants => brands}/api.py (75%)
create mode 100644 authentik/brands/apps.py
create mode 100644 authentik/brands/middleware.py
rename authentik/{tenants => brands}/migrations/0001_squashed_0005_tenant_web_certificate.py (80%)
rename authentik/{tenants => brands}/migrations/0002_tenant_flow_user_settings.py (79%)
rename authentik/{tenants => brands}/migrations/0003_tenant_attributes.py (76%)
rename authentik/{tenants => brands}/migrations/0004_tenant_flow_device_code.py (79%)
create mode 100644 authentik/brands/migrations/0005_tenantuuid_to_branduuid.py
create mode 100644 authentik/brands/migrations/__init__.py
create mode 100644 authentik/brands/models.py
create mode 100644 authentik/brands/tests.py
create mode 100644 authentik/brands/urls.py
create mode 100644 authentik/brands/utils.py
create mode 100644 authentik/events/migrations/0003_rename_tenant_event_brand.py
create mode 100644 authentik/rbac/migrations/0003_alter_systempermission_options.py
create mode 100644 authentik/recovery/lib.py
create mode 100644 authentik/root/storages.py
create mode 100644 authentik/tenants/api/__init__.py
create mode 100644 authentik/tenants/api/domains.py
create mode 100644 authentik/tenants/api/settings.py
create mode 100644 authentik/tenants/api/tenants.py
create mode 100644 authentik/tenants/checks.py
create mode 100644 authentik/tenants/management/__init__.py
delete mode 100644 authentik/tenants/middleware.py
create mode 100644 authentik/tenants/migrations/0001_initial.py
create mode 100644 authentik/tenants/scheduler.py
delete mode 100644 authentik/tenants/tests.py
create mode 100644 authentik/tenants/tests/__init__.py
create mode 100644 authentik/tenants/tests/test_api.py
create mode 100644 authentik/tenants/tests/test_domain.py
create mode 100644 authentik/tenants/tests/test_event_retention.py
create mode 100644 authentik/tenants/tests/test_recovery.py
create mode 100644 authentik/tenants/tests/test_settings.py
create mode 100644 authentik/tenants/tests/utils.py
create mode 100644 blueprints/default/default-brand.yaml
delete mode 100644 blueprints/default/default-tenant.yaml
rename internal/web/{tenant_tls/tenant_tls.go => brand_tls/brand_tls.go} (74%)
create mode 100644 lifecycle/system_migrations/tenant_files.py
create mode 100644 lifecycle/system_migrations/tenant_to_brand.py
create mode 100755 scripts/create_bucket.sh
create mode 100644 web/src/admin/admin-settings/AdminSettingsForm.ts
create mode 100644 web/src/admin/admin-settings/AdminSettingsPage.ts
rename web/src/admin/{tenants/TenantForm.ts => brands/BrandForm.ts} (79%)
rename web/src/admin/{tenants/TenantListPage.ts => brands/BrandListPage.ts} (71%)
create mode 100644 web/src/admin/common/ak-flow-search/ak-branded-flow-search.ts
delete mode 100644 web/src/admin/common/ak-flow-search/ak-tenanted-flow-search.ts
create mode 100644 web/src/elements/Interface/brandProvider.ts
delete mode 100644 web/src/elements/Interface/tenantProvider.ts
create mode 100644 website/docs/advanced/tenancy.md
rename website/docs/core/{tenants.md => brands.md} (62%)
create mode 100644 website/docs/installation/storage-s3.md
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 %}
`}
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) {

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 {