From 41af4860066d897fefd2958992a66cf2b0fc0ba7 Mon Sep 17 00:00:00 2001 From: Jens L Date: Mon, 17 Jul 2023 17:57:08 +0200 Subject: [PATCH] enterprise: initial enterprise (#5721) * initial Signed-off-by: Jens Langhammer * add user type Signed-off-by: Jens Langhammer * add external users Signed-off-by: Jens Langhammer * add ui, add more logic, add public JWT validation key Signed-off-by: Jens Langhammer * revert to not use install_id as session jwt signing key Signed-off-by: Jens Langhammer * fix more Signed-off-by: Jens Langhammer * switch to PKI Signed-off-by: Jens Langhammer * add more licensing stuff Signed-off-by: Jens Langhammer * add install ID to form Signed-off-by: Jens Langhammer * fix bugs Signed-off-by: Jens Langhammer * start adding tests Signed-off-by: Jens Langhammer * fixes Signed-off-by: Jens Langhammer * use x5c correctly Signed-off-by: Jens Langhammer * license checks Signed-off-by: Jens Langhammer * use production CA Signed-off-by: Jens Langhammer * more Signed-off-by: Jens Langhammer * more UI stuff Signed-off-by: Jens Langhammer * rename to summary Signed-off-by: Jens Langhammer * update locale, improve ui Signed-off-by: Jens Langhammer * add direct button Signed-off-by: Jens Langhammer * update link Signed-off-by: Jens Langhammer * format and such Signed-off-by: Jens Langhammer * remove old attributes from ldap Signed-off-by: Jens Langhammer * remove is_enterprise_licensed Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * fix admin interface styling issue Signed-off-by: Jens Langhammer * Update authentik/core/models.py Co-authored-by: Tana M Berry Signed-off-by: Jens L. * fix default case Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer Signed-off-by: Jens L. Co-authored-by: Tana M Berry --- .gitignore | 1 + authentik/api/tests/test_auth.py | 6 +- authentik/api/v3/config.py | 6 + authentik/core/api/users.py | 20 +- authentik/core/migrations/0030_user_type.py | 43 ++ authentik/core/models.py | 19 +- authentik/core/signals.py | 11 +- authentik/core/tests/test_users_api.py | 14 +- authentik/enterprise/__init__.py | 0 authentik/enterprise/api.py | 150 +++++ authentik/enterprise/apps.py | 4 + .../enterprise/migrations/0001_initial.py | 52 ++ authentik/enterprise/migrations/__init__.py | 0 authentik/enterprise/models.py | 185 +++++++ authentik/enterprise/policy.py | 46 ++ authentik/enterprise/public.pem | 26 + authentik/enterprise/settings.py | 11 + authentik/enterprise/signals.py | 18 + authentik/enterprise/tasks.py | 10 + authentik/enterprise/tests/__init__.py | 0 authentik/enterprise/tests/test_license.py | 64 +++ authentik/enterprise/urls.py | 7 + authentik/lib/tests/test_http.py | 4 +- authentik/lib/utils/http.py | 7 +- authentik/outposts/models.py | 6 +- authentik/policies/engine.py | 4 +- .../providers/oauth2/tests/test_token_cc.py | 4 +- authentik/providers/scim/models.py | 23 +- authentik/root/settings.py | 1 + authentik/stages/prompt/stage.py | 2 +- blueprints/schema.json | 60 ++ internal/outpost/ldap/search/direct/schema.go | 4 +- locale/en/LC_MESSAGES/django.po | 100 ++-- schema.yml | 514 ++++++++++++++++++ tests/e2e/test_provider_ldap.py | 4 - web/.prettierignore | 1 + web/src/admin/AdminInterface.ts | 33 +- web/src/admin/Routes.ts | 4 + .../admin-overview/cards/SystemStatusCard.ts | 1 + .../admin/enterprise/EnterpriseLicenseForm.ts | 64 +++ .../enterprise/EnterpriseLicenseListPage.ts | 222 ++++++++ web/src/admin/users/UserForm.ts | 27 + web/src/elements/cards/AggregateCard.ts | 4 + .../enterprise/EnterpriseStatusBanner.ts | 52 ++ web/src/elements/table/TablePage.ts | 14 +- web/src/user/UserInterface.ts | 34 +- web/xliff/de.xlf | 78 +++ web/xliff/en.xlf | 78 +++ web/xliff/es.xlf | 78 +++ web/xliff/fr_FR.xlf | 78 +++ web/xliff/pl.xlf | 78 +++ web/xliff/pseudo-LOCALE.xlf | 78 +++ web/xliff/tr.xlf | 78 +++ web/xliff/zh-Hans.xlf | 78 +++ web/xliff/zh-Hant.xlf | 78 +++ web/xliff/zh_TW.xlf | 78 +++ 56 files changed, 2534 insertions(+), 128 deletions(-) create mode 100644 authentik/core/migrations/0030_user_type.py create mode 100644 authentik/enterprise/__init__.py create mode 100644 authentik/enterprise/api.py create mode 100644 authentik/enterprise/migrations/0001_initial.py create mode 100644 authentik/enterprise/migrations/__init__.py create mode 100644 authentik/enterprise/models.py create mode 100644 authentik/enterprise/policy.py create mode 100644 authentik/enterprise/public.pem create mode 100644 authentik/enterprise/signals.py create mode 100644 authentik/enterprise/tasks.py create mode 100644 authentik/enterprise/tests/__init__.py create mode 100644 authentik/enterprise/tests/test_license.py create mode 100644 authentik/enterprise/urls.py create mode 100644 web/src/admin/enterprise/EnterpriseLicenseForm.ts create mode 100644 web/src/admin/enterprise/EnterpriseLicenseListPage.ts create mode 100644 web/src/elements/enterprise/EnterpriseStatusBanner.ts diff --git a/.gitignore b/.gitignore index 164dc05a1..f0e8bfb2d 100644 --- a/.gitignore +++ b/.gitignore @@ -204,3 +204,4 @@ data/ # Local Netlify folder .netlify +.ruff_cache diff --git a/authentik/api/tests/test_auth.py b/authentik/api/tests/test_auth.py index 510fff6d6..cd23a1835 100644 --- a/authentik/api/tests/test_auth.py +++ b/authentik/api/tests/test_auth.py @@ -9,7 +9,7 @@ from rest_framework.exceptions import AuthenticationFailed from authentik.api.authentication import bearer_auth from authentik.blueprints.tests import reconcile_app -from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents +from authentik.core.models import Token, TokenIntents, User, UserTypes from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.lib.generators import generate_id from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API @@ -57,8 +57,8 @@ class TestAPIAuth(TestCase): @reconcile_app("authentik_outposts") def test_managed_outpost_success(self): """Test managed outpost""" - user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) - self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True) + user: User = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) + self.assertEqual(user.type, UserTypes.INTERNAL_SERVICE_ACCOUNT) def test_jwt_valid(self): """Test valid JWT""" diff --git a/authentik/api/v3/config.py b/authentik/api/v3/config.py index 640e03453..856ae94a7 100644 --- a/authentik/api/v3/config.py +++ b/authentik/api/v3/config.py @@ -3,6 +3,7 @@ from pathlib import Path from django.conf import settings from django.db import models +from django.dispatch import Signal from drf_spectacular.utils import extend_schema from rest_framework.fields import ( BooleanField, @@ -21,6 +22,8 @@ from authentik.core.api.utils import PassiveSerializer from authentik.events.geo import GEOIP_READER from authentik.lib.config import CONFIG +capabilities = Signal() + class Capabilities(models.TextChoices): """Define capabilities which influence which APIs can/should be used""" @@ -73,6 +76,9 @@ class ConfigView(APIView): caps.append(Capabilities.CAN_DEBUG) if "authentik.enterprise" in settings.INSTALLED_APPS: caps.append(Capabilities.IS_ENTERPRISE) + for _, result in capabilities.send(sender=self): + if result: + caps.append(result) return caps def get_config(self) -> ConfigSerializer: diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 123ae0fb9..4c2aae74d 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -59,7 +59,6 @@ from authentik.core.middleware import ( SESSION_KEY_IMPERSONATE_USER, ) from authentik.core.models import ( - USER_ATTRIBUTE_SA, USER_ATTRIBUTE_TOKEN_EXPIRING, USER_PATH_SERVICE_ACCOUNT, AuthenticatedSession, @@ -67,6 +66,7 @@ from authentik.core.models import ( Token, TokenIntents, User, + UserTypes, ) from authentik.events.models import Event, EventAction from authentik.flows.exceptions import FlowNonApplicableException @@ -147,6 +147,18 @@ class UserSerializer(ModelSerializer): raise ValidationError(_("No empty segments in user path allowed.")) return path + def validate_type(self, user_type: str) -> str: + """Validate user type, internal_service_account is an internal value""" + if ( + self.instance + and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT + and user_type != UserTypes.INTERNAL_SERVICE_ACCOUNT.value + ): + raise ValidationError("Can't change internal service account to other user type.") + if not self.instance and user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT.value: + raise ValidationError("Setting a user to internal service account is not allowed.") + return user_type + class Meta: model = User fields = [ @@ -163,6 +175,7 @@ class UserSerializer(ModelSerializer): "attributes", "uid", "path", + "type", ] extra_kwargs = { "name": {"allow_blank": True}, @@ -211,6 +224,7 @@ class UserSelfSerializer(ModelSerializer): "avatar", "uid", "settings", + "type", ] extra_kwargs = { "is_active": {"read_only": True}, @@ -329,6 +343,7 @@ class UsersFilter(FilterSet): "attributes", "groups_by_name", "groups_by_pk", + "type", ] @@ -421,7 +436,8 @@ class UserViewSet(UsedByMixin, ModelViewSet): user: User = User.objects.create( username=username, name=username, - attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: expiring}, + type=UserTypes.SERVICE_ACCOUNT, + attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: expiring}, path=USER_PATH_SERVICE_ACCOUNT, ) user.set_unusable_password() diff --git a/authentik/core/migrations/0030_user_type.py b/authentik/core/migrations/0030_user_type.py new file mode 100644 index 000000000..93a7a8884 --- /dev/null +++ b/authentik/core/migrations/0030_user_type.py @@ -0,0 +1,43 @@ +# Generated by Django 4.1.7 on 2023-05-21 11:44 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_user_type(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + User = apps.get_model("authentik_core", "User") + + from authentik.core.models import UserTypes + + for user in User.objects.using(db_alias).all(): + user.type = UserTypes.DEFAULT + if "goauthentik.io/user/service-account" in user.attributes: + user.type = UserTypes.SERVICE_ACCOUNT + if "goauthentik.io/user/override-ips" in user.attributes: + user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT + user.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_core", "0029_provider_backchannel_applications_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="type", + field=models.TextField( + choices=[ + ("default", "Default"), + ("external", "External"), + ("service_account", "Service Account"), + ("internal_service_account", "Internal Service Account"), + ], + default="default", + ), + ), + migrations.RunPython(migrate_user_type), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 310497856..383c0f2f7 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -36,7 +36,6 @@ from authentik.root.install_id import get_install_id LOGGER = get_logger() USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" -USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" USER_ATTRIBUTE_GENERATED = "goauthentik.io/user/generated" USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires" USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout" @@ -45,8 +44,6 @@ USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username" USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name" USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email" -USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" - USER_PATH_SYSTEM_PREFIX = "goauthentik.io" USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts" @@ -66,6 +63,21 @@ def default_token_key(): return generate_id(int(CONFIG.y("default_token_length"))) +class UserTypes(models.TextChoices): + """User types, both for grouping, licensing and permissions in the case + of the internal_service_account""" + + DEFAULT = "default" + EXTERNAL = "external" + + # User-created service accounts + SERVICE_ACCOUNT = "service_account" + + # Special user type for internally managed and created service + # accounts, such as outpost users + INTERNAL_SERVICE_ACCOUNT = "internal_service_account" + + class Group(SerializerModel): """Custom Group model which supports a basic hierarchy""" @@ -149,6 +161,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): uuid = models.UUIDField(default=uuid4, editable=False, unique=True) name = models.TextField(help_text=_("User's display name.")) path = models.TextField(default="users") + type = models.TextField(choices=UserTypes.choices, default=UserTypes.DEFAULT) sources = models.ManyToManyField("Source", through="UserSourceConnection") ak_groups = models.ManyToManyField("Group", related_name="users") diff --git a/authentik/core/signals.py b/authentik/core/signals.py index ca6d2c527..76cbd38ff 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -1,6 +1,4 @@ """authentik core signals""" -from typing import TYPE_CHECKING - from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.sessions.backends.cache import KEY_PREFIX from django.core.cache import cache @@ -10,16 +8,13 @@ from django.db.models.signals import post_save, pre_delete, pre_save from django.dispatch import receiver from django.http.request import HttpRequest -from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider +from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider, User # Arguments: user: User, password: str password_changed = Signal() # Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage login_failed = Signal() -if TYPE_CHECKING: - from authentik.core.models import User - @receiver(post_save, sender=Application) def post_save_application(sender: type[Model], instance, created: bool, **_): @@ -35,7 +30,7 @@ def post_save_application(sender: type[Model], instance, created: bool, **_): @receiver(user_logged_in) -def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): +def user_logged_in_session(sender, request: HttpRequest, user: User, **_): """Create an AuthenticatedSession from request""" session = AuthenticatedSession.from_request(request, user) @@ -44,7 +39,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): @receiver(user_logged_out) -def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): +def user_logged_out_session(sender, request: HttpRequest, user: User, **_): """Delete AuthenticatedSession if it exists""" AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete() diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 79e60335e..60a1d61fc 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -8,11 +8,11 @@ from django.urls.base import reverse from rest_framework.test import APITestCase from authentik.core.models import ( - USER_ATTRIBUTE_SA, USER_ATTRIBUTE_TOKEN_EXPIRING, AuthenticatedSession, Token, User, + UserTypes, ) from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant from authentik.flows.models import FlowDesignation @@ -141,7 +141,8 @@ class TestUsersAPI(APITestCase): user_filter = User.objects.filter( username="test-sa", - attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True}, + type=UserTypes.SERVICE_ACCOUNT, + attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True}, ) self.assertTrue(user_filter.exists()) user: User = user_filter.first() @@ -166,7 +167,8 @@ class TestUsersAPI(APITestCase): user_filter = User.objects.filter( username="test-sa", - attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False, USER_ATTRIBUTE_SA: True}, + type=UserTypes.SERVICE_ACCOUNT, + attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False}, ) self.assertTrue(user_filter.exists()) user: User = user_filter.first() @@ -192,7 +194,8 @@ class TestUsersAPI(APITestCase): user_filter = User.objects.filter( username="test-sa", - attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True}, + type=UserTypes.SERVICE_ACCOUNT, + attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True}, ) self.assertTrue(user_filter.exists()) user: User = user_filter.first() @@ -218,7 +221,8 @@ class TestUsersAPI(APITestCase): user_filter = User.objects.filter( username="test-sa", - attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True}, + type=UserTypes.SERVICE_ACCOUNT, + attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True}, ) self.assertTrue(user_filter.exists()) user: User = user_filter.first() diff --git a/authentik/enterprise/__init__.py b/authentik/enterprise/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/api.py b/authentik/enterprise/api.py new file mode 100644 index 000000000..d10d5296d --- /dev/null +++ b/authentik/enterprise/api.py @@ -0,0 +1,150 @@ +"""Enterprise API Views""" +from datetime import datetime, timedelta + +from django.utils.timezone import now +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework.decorators import action +from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.api.decorators import permission_required +from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import PassiveSerializer +from authentik.core.models import User, UserTypes +from authentik.enterprise.models import License, LicenseKey +from authentik.root.install_id import get_install_id + + +class LicenseSerializer(ModelSerializer): + """License Serializer""" + + def validate_key(self, key: str) -> str: + """Validate the license key (install_id and signature)""" + LicenseKey.validate(key) + return key + + class Meta: + model = License + fields = [ + "license_uuid", + "name", + "key", + "expiry", + "users", + "external_users", + ] + extra_kwargs = { + "name": {"read_only": True}, + "expiry": {"read_only": True}, + "users": {"read_only": True}, + "external_users": {"read_only": True}, + } + + +class LicenseSummary(PassiveSerializer): + """Serializer for license status""" + + users = IntegerField(required=True) + external_users = IntegerField(required=True) + valid = BooleanField() + show_admin_warning = BooleanField() + show_user_warning = BooleanField() + read_only = BooleanField() + latest_valid = DateTimeField() + has_license = BooleanField() + + +class LicenseForecastSerializer(PassiveSerializer): + """Serializer for license forecast""" + + users = IntegerField(required=True) + external_users = IntegerField(required=True) + + +class LicenseViewSet(UsedByMixin, ModelViewSet): + """License Viewset""" + + queryset = License.objects.all() + serializer_class = LicenseSerializer + search_fields = ["name"] + ordering = ["name"] + filterset_fields = ["name"] + + @permission_required(None, ["authentik_enterprise.view_license"]) + @extend_schema( + request=OpenApiTypes.NONE, + responses={ + 200: inline_serializer("InstallIDSerializer", {"install_id": CharField(required=True)}), + }, + ) + @action(detail=False, methods=["GET"], permission_classes=[IsAdminUser]) + def get_install_id(self, request: Request) -> Response: + """Get install_id""" + return Response( + data={ + "install_id": get_install_id(), + } + ) + + @extend_schema( + request=OpenApiTypes.NONE, + responses={ + 200: LicenseSummary(), + }, + ) + @action(detail=False, methods=["GET"], permission_classes=[IsAuthenticated]) + def summary(self, request: Request) -> Response: + """Get the total license status""" + total = LicenseKey.get_total() + last_valid = LicenseKey.last_valid_date() + # TODO: move this to a different place? + show_admin_warning = last_valid < now() - timedelta(weeks=2) + show_user_warning = last_valid < now() - timedelta(weeks=4) + read_only = last_valid < now() - timedelta(weeks=6) + latest_valid = datetime.fromtimestamp(total.exp) + response = LicenseSummary( + data={ + "users": total.users, + "external_users": total.external_users, + "valid": total.is_valid(), + "show_admin_warning": show_admin_warning, + "show_user_warning": show_user_warning, + "read_only": read_only, + "latest_valid": latest_valid, + "has_license": License.objects.all().count() > 0, + } + ) + response.is_valid(raise_exception=True) + return Response(response.data) + + @permission_required(None, ["authentik_enterprise.view_license"]) + @extend_schema( + request=OpenApiTypes.NONE, + responses={ + 200: LicenseForecastSerializer(), + }, + ) + @action(detail=False, methods=["GET"]) + def forecast(self, request: Request) -> Response: + """Forecast how many users will be required in a year""" + last_month = now() - timedelta(days=30) + # Forecast for default users + users_in_last_month = User.objects.filter( + type=UserTypes.DEFAULT, date_joined__gte=last_month + ).count() + # Forecast for external users + external_in_last_month = LicenseKey.get_external_user_count() + forecast_for_months = 12 + response = LicenseForecastSerializer( + data={ + "users": users_in_last_month * forecast_for_months, + "external_users": external_in_last_month * forecast_for_months, + } + ) + response.is_valid(raise_exception=True) + return Response(response.data) diff --git a/authentik/enterprise/apps.py b/authentik/enterprise/apps.py index 02062aa3e..2d918da17 100644 --- a/authentik/enterprise/apps.py +++ b/authentik/enterprise/apps.py @@ -9,3 +9,7 @@ class AuthentikEnterpriseConfig(ManagedAppConfig): label = "authentik_enterprise" verbose_name = "authentik Enterprise" default = True + + def reconcile_load_enterprise_signals(self): + """Load enterprise signals""" + self.import_module("authentik.enterprise.signals") diff --git a/authentik/enterprise/migrations/0001_initial.py b/authentik/enterprise/migrations/0001_initial.py new file mode 100644 index 000000000..5ba4d79f1 --- /dev/null +++ b/authentik/enterprise/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 4.1.10 on 2023-07-06 12:51 + +import uuid + +from django.db import migrations, models + +import authentik.enterprise.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="License", + fields=[ + ( + "license_uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("key", models.TextField(unique=True)), + ("name", models.TextField()), + ("expiry", models.DateTimeField()), + ("users", models.BigIntegerField()), + ("external_users", models.BigIntegerField()), + ], + ), + migrations.CreateModel( + name="LicenseUsage", + fields=[ + ("expiring", models.BooleanField(default=True)), + ("expires", models.DateTimeField(default=authentik.enterprise.models.usage_expiry)), + ( + "usage_uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("user_count", models.BigIntegerField()), + ("external_user_count", models.BigIntegerField()), + ("within_limits", models.BooleanField()), + ("record_date", models.DateTimeField(auto_now_add=True)), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/authentik/enterprise/migrations/__init__.py b/authentik/enterprise/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/models.py b/authentik/enterprise/models.py new file mode 100644 index 000000000..1abf30da3 --- /dev/null +++ b/authentik/enterprise/models.py @@ -0,0 +1,185 @@ +"""Enterprise models""" +from base64 import b64decode +from binascii import Error +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import Enum +from functools import lru_cache +from time import mktime +from uuid import uuid4 + +from cryptography.exceptions import InvalidSignature +from cryptography.x509 import Certificate, load_pem_x509_certificate +from dacite import from_dict +from django.db import models +from django.db.models.query import QuerySet +from django.utils.timezone import now +from guardian.shortcuts import get_anonymous_user +from jwt import PyJWTError, decode, get_unverified_header +from rest_framework.exceptions import ValidationError + +from authentik.core.models import ExpiringModel, User, UserTypes +from authentik.root.install_id import get_install_id + + +@lru_cache() +def get_licensing_key() -> Certificate: + """Get Root CA PEM""" + with open("authentik/enterprise/public.pem", "rb") as _key: + return load_pem_x509_certificate(_key.read()) + + +def get_license_aud() -> str: + """Get the JWT audience field""" + return f"enterprise.goauthentik.io/license/{get_install_id()}" + + +class LicenseFlags(Enum): + """License flags""" + + +@dataclass +class LicenseKey: + """License JWT claims""" + + aud: str + exp: int + + name: str + users: int + external_users: int + flags: list[LicenseFlags] = field(default_factory=list) + + @staticmethod + def validate(jwt: str) -> "LicenseKey": + """Validate the license from a given JWT""" + try: + headers = get_unverified_header(jwt) + except PyJWTError: + raise ValidationError("Unable to verify license") + x5c: list[str] = headers.get("x5c", []) + if len(x5c) < 1: + raise ValidationError("Unable to verify license") + try: + our_cert = load_pem_x509_certificate(b64decode(x5c[0])) + intermediate = load_pem_x509_certificate(b64decode(x5c[1])) + our_cert.verify_directly_issued_by(intermediate) + intermediate.verify_directly_issued_by(get_licensing_key()) + except (InvalidSignature, TypeError, ValueError, Error): + raise ValidationError("Unable to verify license") + try: + body = from_dict( + LicenseKey, + decode( + jwt, + our_cert.public_key(), + algorithms=["ES521"], + audience=get_license_aud(), + ), + ) + except PyJWTError: + raise ValidationError("Unable to verify license") + return body + + @staticmethod + def get_total() -> "LicenseKey": + """Get a summarized version of all (not expired) licenses""" + active_licenses = License.objects.filter(expiry__gte=now()) + total = LicenseKey(get_license_aud(), 0, "Summarized license", 0, 0) + for lic in active_licenses: + total.users += lic.users + total.external_users += lic.external_users + exp_ts = int(mktime(lic.expiry.timetuple())) + if total.exp == 0: + total.exp = exp_ts + if exp_ts <= total.exp: + total.exp = exp_ts + total.flags.extend(lic.status.flags) + return total + + @staticmethod + def base_user_qs() -> QuerySet: + """Base query set for all users""" + return User.objects.all().exclude(pk=get_anonymous_user().pk) + + @staticmethod + def get_default_user_count(): + """Get current default user count""" + return LicenseKey.base_user_qs().filter(type=UserTypes.DEFAULT).count() + + @staticmethod + def get_external_user_count(): + """Get current external user count""" + # Count since start of the month + last_month = now().replace(day=1) + return ( + LicenseKey.base_user_qs() + .filter(type=UserTypes.EXTERNAL, last_login__gte=last_month) + .count() + ) + + def is_valid(self) -> bool: + """Check if the given license body covers all users + + Only checks the current count, no historical data is checked""" + default_users = self.get_default_user_count() + if default_users > self.users: + return False + active_users = self.get_external_user_count() + if active_users > self.external_users: + return False + return True + + def record_usage(self): + """Capture the current validity status and metrics and save them""" + LicenseUsage.objects.create( + user_count=self.get_default_user_count(), + external_user_count=self.get_external_user_count(), + within_limits=self.is_valid(), + ) + + @staticmethod + def last_valid_date() -> datetime: + """Get the last date the license was valid""" + usage: LicenseUsage = ( + LicenseUsage.filter_not_expired(within_limits=True).order_by("-record_date").first() + ) + if not usage: + return now() + return usage.record_date + + +class License(models.Model): + """An authentik enterprise license""" + + license_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + key = models.TextField(unique=True) + + name = models.TextField() + expiry = models.DateTimeField() + users = models.BigIntegerField() + external_users = models.BigIntegerField() + + @property + def status(self) -> LicenseKey: + """Get parsed license status""" + return LicenseKey.validate(self.key) + + +def usage_expiry(): + """Keep license usage records for 3 months""" + return now() + timedelta(days=30 * 3) + + +class LicenseUsage(ExpiringModel): + """a single license usage record""" + + expires = models.DateTimeField(default=usage_expiry) + + usage_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + user_count = models.BigIntegerField() + external_user_count = models.BigIntegerField() + within_limits = models.BooleanField() + + record_date = models.DateTimeField(auto_now_add=True) diff --git a/authentik/enterprise/policy.py b/authentik/enterprise/policy.py new file mode 100644 index 000000000..c736bd59e --- /dev/null +++ b/authentik/enterprise/policy.py @@ -0,0 +1,46 @@ +"""Enterprise license policies""" +from typing import Optional + +from rest_framework.serializers import BaseSerializer + +from authentik.core.models import User, UserTypes +from authentik.enterprise.models import LicenseKey +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult +from authentik.policies.views import PolicyAccessView + + +class EnterprisePolicy(Policy): + """Check that a user is correctly licensed for the request""" + + @property + def component(self) -> str: + return "" + + @property + def serializer(self) -> type[BaseSerializer]: + raise NotImplementedError + + def passes(self, request: PolicyRequest) -> PolicyResult: + if not LicenseKey.get_total().is_valid(): + return PolicyResult(False) + if request.user.type != UserTypes.DEFAULT: + return PolicyResult(False) + return PolicyResult(True) + + +class EnterprisePolicyAccessView(PolicyAccessView): + """PolicyAccessView which also checks enterprise licensing""" + + def user_has_access(self, user: Optional[User] = None) -> PolicyResult: + user = user or self.request.user + request = PolicyRequest(user) + request.http_request = self.request + result = super().user_has_access(user) + enterprise_result = EnterprisePolicy().passes(request) + if not enterprise_result.passing: + return enterprise_result + return result + + def resolve_provider_application(self): + raise NotImplementedError diff --git a/authentik/enterprise/public.pem b/authentik/enterprise/public.pem new file mode 100644 index 000000000..948c0c9ec --- /dev/null +++ b/authentik/enterprise/public.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEdzCCA/6gAwIBAgIUQrj1jxn4q/BB38B2SwTrvGyrZLMwCgYIKoZIzj0EAwMw +ge8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1T +YW4gRnJhbmNpc2NvMSQwIgYDVQQJExs1NDggTWFya2V0IFN0cmVldCBQbWIgNzAx +NDgxDjAMBgNVBBETBTk0MTA0MSAwHgYDVQQKExdBdXRoZW50aWsgU2VjdXJpdHkg +SW5jLjEcMBoGA1UECxMTRW50ZXJwcmlzZSBMaWNlbnNlczE9MDsGA1UEAxM0QXV0 +aGVudGlrIFNlY3VyaXR5IEluYy4gRW50ZXJwcmlzZSBMaWNlbnNpbmcgUm9vdCBY +MTAgFw0yMzA3MDQxNzQ3NDBaGA8yMTIzMDYxMDE3NDgxMFowge8xCzAJBgNVBAYT +AlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2Nv +MSQwIgYDVQQJExs1NDggTWFya2V0IFN0cmVldCBQbWIgNzAxNDgxDjAMBgNVBBET +BTk0MTA0MSAwHgYDVQQKExdBdXRoZW50aWsgU2VjdXJpdHkgSW5jLjEcMBoGA1UE +CxMTRW50ZXJwcmlzZSBMaWNlbnNlczE9MDsGA1UEAxM0QXV0aGVudGlrIFNlY3Vy +aXR5IEluYy4gRW50ZXJwcmlzZSBMaWNlbnNpbmcgUm9vdCBYMTB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABNbPJH6nDbSshpDsDHBRL0UcZVXWCK30txqcMKU+YFmLB6iR +PJiHjHA8Z+5aP4eNH6onA5xqykQf65tvbFBA1LB/6HqMArU/tYVVQx4+o9hRBxF5 +RrzXucUg2br+RX8aa6OCAVUwggFRMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBRHpR3/ptPgN0yHVfUjyJOEmsPZqTAfBgNVHSMEGDAW +gBRHpR3/ptPgN0yHVfUjyJOEmsPZqTCBoAYIKwYBBQUHAQEEgZMwgZAwRwYIKwYB +BQUHMAGGO2h0dHBzOi8vdmF1bHQuY3VzdG9tZXJzLmdvYXV0aGVudGlrLmlvL3Yx +L2xpY2Vuc2luZy1jYS9vY3NwMEUGCCsGAQUFBzAChjlodHRwczovL3ZhdWx0LmN1 +c3RvbWVycy5nb2F1dGhlbnRpay5pby92MS9saWNlbnNpbmctY2EvY2EwSwYDVR0f +BEQwQjBAoD6gPIY6aHR0cHM6Ly92YXVsdC5jdXN0b21lcnMuZ29hdXRoZW50aWsu +aW8vdjEvbGljZW5zaW5nLWNhL2NybDAKBggqhkjOPQQDAwNnADBkAjB0+YA1yjEO +g43CCYUJXz9m9CNIkjOPUI0jO4UtvSj8j067TKRbX6IL/29HxPtQoYACME8eZHBJ +Ljcog0oeBgjr4wK8bobgknr5wrm70rrNNpbSAjDvTvXMQeAShGgsftEquQ== +-----END CERTIFICATE----- diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 916dbce3b..af1da7294 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -1 +1,12 @@ """Enterprise additional settings""" +from celery.schedules import crontab + +from authentik.lib.utils.time import fqdn_rand + +CELERY_BEAT_SCHEDULE = { + "enterprise_calculate_license": { + "task": "authentik.enterprise.tasks.calculate_license", + "schedule": crontab(minute=fqdn_rand("calculate_license"), hour="*/8"), + "options": {"queue": "authentik_scheduled"}, + } +} diff --git a/authentik/enterprise/signals.py b/authentik/enterprise/signals.py new file mode 100644 index 000000000..b891f8f2c --- /dev/null +++ b/authentik/enterprise/signals.py @@ -0,0 +1,18 @@ +"""Enterprise signals""" +from datetime import datetime + +from django.db.models.signals import pre_save +from django.dispatch import receiver +from django.utils.timezone import get_current_timezone + +from authentik.enterprise.models import License + + +@receiver(pre_save, sender=License) +def pre_save_license(sender: type[License], instance: License, **_): + """Extract data from license jwt and save it into model""" + status = instance.status + instance.name = status.name + instance.users = status.users + instance.external_users = status.external_users + instance.expiry = datetime.fromtimestamp(status.exp, tz=get_current_timezone()) diff --git a/authentik/enterprise/tasks.py b/authentik/enterprise/tasks.py new file mode 100644 index 000000000..2931a726b --- /dev/null +++ b/authentik/enterprise/tasks.py @@ -0,0 +1,10 @@ +"""Enterprise tasks""" +from authentik.enterprise.models import LicenseKey +from authentik.root.celery import CELERY_APP + + +@CELERY_APP.task() +def calculate_license(): + """Calculate licensing status""" + total = LicenseKey.get_total() + total.record_usage() diff --git a/authentik/enterprise/tests/__init__.py b/authentik/enterprise/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/tests/test_license.py b/authentik/enterprise/tests/test_license.py new file mode 100644 index 000000000..6e21e6d11 --- /dev/null +++ b/authentik/enterprise/tests/test_license.py @@ -0,0 +1,64 @@ +"""Enterprise license tests""" +from datetime import timedelta +from time import mktime +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils.timezone import now +from rest_framework.exceptions import ValidationError + +from authentik.enterprise.models import License, LicenseKey +from authentik.lib.generators import generate_id + +_exp = int(mktime((now() + timedelta(days=3000)).timetuple())) + + +class TestEnterpriseLicense(TestCase): + """Enterprise license tests""" + + @patch( + "authentik.enterprise.models.LicenseKey.validate", + MagicMock( + return_value=LicenseKey( + aud="", + exp=_exp, + name=generate_id(), + users=100, + external_users=100, + ) + ), + ) + def test_valid(self): + """Check license verification""" + lic = License.objects.create(key=generate_id()) + self.assertTrue(lic.status.is_valid()) + self.assertEqual(lic.users, 100) + + def test_invalid(self): + """Test invalid license""" + with self.assertRaises(ValidationError): + License.objects.create(key=generate_id()) + + @patch( + "authentik.enterprise.models.LicenseKey.validate", + MagicMock( + return_value=LicenseKey( + aud="", + exp=_exp, + name=generate_id(), + users=100, + external_users=100, + ) + ), + ) + def test_valid_multiple(self): + """Check license verification""" + lic = License.objects.create(key=generate_id()) + self.assertTrue(lic.status.is_valid()) + lic2 = License.objects.create(key=generate_id()) + self.assertTrue(lic2.status.is_valid()) + total = LicenseKey.get_total() + self.assertEqual(total.users, 200) + self.assertEqual(total.external_users, 200) + self.assertEqual(total.exp, _exp) + self.assertTrue(total.is_valid()) diff --git a/authentik/enterprise/urls.py b/authentik/enterprise/urls.py new file mode 100644 index 000000000..99cec680e --- /dev/null +++ b/authentik/enterprise/urls.py @@ -0,0 +1,7 @@ +"""API URLs""" + +from authentik.enterprise.api import LicenseViewSet + +api_urlpatterns = [ + ("enterprise/license", LicenseViewSet), +] diff --git a/authentik/lib/tests/test_http.py b/authentik/lib/tests/test_http.py index 046fcab3c..e554088cc 100644 --- a/authentik/lib/tests/test_http.py +++ b/authentik/lib/tests/test_http.py @@ -1,7 +1,7 @@ """Test HTTP Helpers""" from django.test import RequestFactory, TestCase -from authentik.core.models import USER_ATTRIBUTE_CAN_OVERRIDE_IP, Token, TokenIntents +from authentik.core.models import Token, TokenIntents, UserTypes from authentik.core.tests.utils import create_test_admin_user from authentik.lib.utils.http import OUTPOST_REMOTE_IP_HEADER, OUTPOST_TOKEN_HEADER, get_client_ip from authentik.lib.views import bad_request_message @@ -53,7 +53,7 @@ class TestHTTP(TestCase): ) self.assertEqual(get_client_ip(request), "127.0.0.1") # Valid - self.user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True + self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT self.user.save() request = self.factory.get( "/", diff --git a/authentik/lib/utils/http.py b/authentik/lib/utils/http.py index bce4fdbaa..adb3ec099 100644 --- a/authentik/lib/utils/http.py +++ b/authentik/lib/utils/http.py @@ -33,9 +33,8 @@ def _get_client_ip_from_meta(meta: dict[str, Any]) -> str: def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: """Get the actual remote IP when set by an outpost. Only - allowed when the request is authenticated, by a user with USER_ATTRIBUTE_CAN_OVERRIDE_IP set - to outpost""" - from authentik.core.models import USER_ATTRIBUTE_CAN_OVERRIDE_IP, Token, TokenIntents + allowed when the request is authenticated, by an outpost internal service account""" + from authentik.core.models import Token, TokenIntents, UserTypes if OUTPOST_REMOTE_IP_HEADER not in request.META or OUTPOST_TOKEN_HEADER not in request.META: return None @@ -51,7 +50,7 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: LOGGER.warning("Attempted remote-ip override without token", fake_ip=fake_ip) return None user = token.user - if not user.group_attributes(request).get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): + if user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: LOGGER.warning( "Remote-IP override: user doesn't have permission", user=user, diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 9aedc205f..c0217a0f4 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -20,13 +20,12 @@ from structlog.stdlib import get_logger from authentik import __version__, get_build_hash from authentik.blueprints.models import ManagedModel from authentik.core.models import ( - USER_ATTRIBUTE_CAN_OVERRIDE_IP, - USER_ATTRIBUTE_SA, USER_PATH_SYSTEM_PREFIX, Provider, Token, TokenIntents, User, + UserTypes, ) from authentik.crypto.models import CertificateKeyPair from authentik.events.models import Event, EventAction @@ -346,8 +345,7 @@ class Outpost(SerializerModel, ManagedModel): user: User = User.objects.create(username=self.user_identifier) user.set_unusable_password() user_created = True - user.attributes[USER_ATTRIBUTE_SA] = True - user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True + user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT user.name = f"Outpost {self.name} Service-Account" user.path = USER_PATH_OUTPOSTS user.save() diff --git a/authentik/policies/engine.py b/authentik/policies/engine.py index 22ea69014..a3d8a3191 100644 --- a/authentik/policies/engine.py +++ b/authentik/policies/engine.py @@ -64,7 +64,7 @@ class PolicyEngine: self.use_cache = True self.__expected_result_count = 0 - def _iter_bindings(self) -> Iterator[PolicyBinding]: + def iterate_bindings(self) -> Iterator[PolicyBinding]: """Make sure all Policies are their respective classes""" return ( PolicyBinding.objects.filter(target=self.__pbm, enabled=True) @@ -88,7 +88,7 @@ class PolicyEngine: span: Span span.set_data("pbm", self.__pbm) span.set_data("request", self.request) - for binding in self._iter_bindings(): + for binding in self.iterate_bindings(): self.__expected_result_count += 1 self._check_policy_type(binding) diff --git a/authentik/providers/oauth2/tests/test_token_cc.py b/authentik/providers/oauth2/tests/test_token_cc.py index e9213e211..7dc086ba0 100644 --- a/authentik/providers/oauth2/tests/test_token_cc.py +++ b/authentik/providers/oauth2/tests/test_token_cc.py @@ -6,7 +6,7 @@ from django.urls import reverse from jwt import decode from authentik.blueprints.tests import apply_blueprint -from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group, Token, TokenIntents +from authentik.core.models import Application, Group, Token, TokenIntents, UserTypes from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.constants import ( @@ -37,7 +37,7 @@ class TestTokenClientCredentials(OAuthTestCase): self.provider.property_mappings.set(ScopeMapping.objects.all()) self.app = Application.objects.create(name="test", slug="test", provider=self.provider) self.user = create_test_admin_user("sa") - self.user.attributes[USER_ATTRIBUTE_SA] = True + self.user.type = UserTypes.SERVICE_ACCOUNT self.user.save() self.token = Token.objects.create( identifier="sa-token", diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 96c4b55d4..ac27288f3 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -1,17 +1,11 @@ """SCIM Provider models""" from django.db import models -from django.db.models import Q, QuerySet +from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ from guardian.shortcuts import get_anonymous_user from rest_framework.serializers import Serializer -from authentik.core.models import ( - USER_ATTRIBUTE_SA, - BackchannelProvider, - Group, - PropertyMapping, - User, -) +from authentik.core.models import BackchannelProvider, Group, PropertyMapping, User, UserTypes class SCIMProvider(BackchannelProvider): @@ -38,17 +32,8 @@ class SCIMProvider(BackchannelProvider): according to the provider's settings""" base = User.objects.all().exclude(pk=get_anonymous_user().pk) if self.exclude_users_service_account: - base = base.filter( - Q( - **{ - f"attributes__{USER_ATTRIBUTE_SA}__isnull": True, - } - ) - | Q( - **{ - f"attributes__{USER_ATTRIBUTE_SA}": False, - } - ) + base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( + type=UserTypes.INTERNAL_SERVICE_ACCOUNT ) if self.filter_group: base = base.filter(ak_groups__in=[self.filter_group]) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 99918512a..7a4659d24 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -146,6 +146,7 @@ SPECTACULAR_SETTINGS = { "PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes", "LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode", "UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification", + "UserTypeEnum": "authentik.core.models.UserTypes", }, "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, "POSTPROCESSING_HOOKS": [ diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py index 1e3c78014..df0af1be7 100644 --- a/authentik/stages/prompt/stage.py +++ b/authentik/stages/prompt/stage.py @@ -179,7 +179,7 @@ class ListPolicyEngine(PolicyEngine): self.__list = policies self.use_cache = False - def _iter_bindings(self) -> Iterator[PolicyBinding]: + def iterate_bindings(self) -> Iterator[PolicyBinding]: for policy in self.__list: yield PolicyBinding( policy=policy, diff --git a/blueprints/schema.json b/blueprints/schema.json index dd9028b8d..7747f8ac4 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -3980,6 +3980,16 @@ "type": "string", "minLength": 1, "title": "Path" + }, + "type": { + "type": "string", + "enum": [ + "default", + "external", + "service_account", + "internal_service_account" + ], + "title": "Type" } }, "required": [ @@ -4171,6 +4181,16 @@ "type": "string", "minLength": 1, "title": "Path" + }, + "type": { + "type": "string", + "enum": [ + "default", + "external", + "service_account", + "internal_service_account" + ], + "title": "Type" } }, "required": [ @@ -4366,6 +4386,16 @@ "type": "string", "minLength": 1, "title": "Path" + }, + "type": { + "type": "string", + "enum": [ + "default", + "external", + "service_account", + "internal_service_account" + ], + "title": "Type" } }, "required": [ @@ -6522,6 +6552,16 @@ "type": "string", "minLength": 1, "title": "Path" + }, + "type": { + "type": "string", + "enum": [ + "default", + "external", + "service_account", + "internal_service_account" + ], + "title": "Type" } }, "required": [ @@ -7257,6 +7297,16 @@ "type": "string", "minLength": 1, "title": "Path" + }, + "type": { + "type": "string", + "enum": [ + "default", + "external", + "service_account", + "internal_service_account" + ], + "title": "Type" } }, "required": [ @@ -8334,6 +8384,16 @@ "minLength": 1, "title": "Path" }, + "type": { + "type": "string", + "enum": [ + "default", + "external", + "service_account", + "internal_service_account" + ], + "title": "Type" + }, "password": { "type": "string", "minLength": 1, diff --git a/internal/outpost/ldap/search/direct/schema.go b/internal/outpost/ldap/search/direct/schema.go index a024667ae..81e601846 100644 --- a/internal/outpost/ldap/search/direct/schema.go +++ b/internal/outpost/ldap/search/direct/schema.go @@ -41,7 +41,7 @@ func (ds *DirectSearcher) SearchSubschema(req *search.Request) (ldap.ServerSearc // Custom attributes // Temporarily use 1.3.6.1.4.1.26027.1.1 as a base // https://docs.oracle.com/cd/E19450-01/820-6169/working-with-object-identifiers.html#obtaining-a-base-oid - "( 1.3.6.1.4.1.26027.1.1.1 NAME 'goauthentik.io/ldap/user' SUP organizationalPerson STRUCTURAL MAY ( ak-active $ sAMAccountName $ goauthentikio-user-sources $ goauthentik.io/user/sources $ goauthentik.io/ldap/active $ goauthentik.io/ldap/superuser $ goauthentikio-user-override-ips $ goauthentikio-user-service-account ) )", + "( 1.3.6.1.4.1.26027.1.1.1 NAME 'goauthentik.io/ldap/user' SUP organizationalPerson STRUCTURAL MAY ( ak-active $ sAMAccountName $ goauthentikio-user-sources $ goauthentik.io/user/sources $ goauthentik.io/ldap/active $ goauthentik.io/ldap/superuser ) )", }, }, { @@ -85,8 +85,6 @@ func (ds *DirectSearcher) SearchSubschema(req *search.Request) (ldap.ServerSearc // https://docs.oracle.com/cd/E19450-01/820-6169/working-with-object-identifiers.html#obtaining-a-base-oid "( 1.3.6.1.4.1.26027.1.1.2 NAME ( 'goauthentik.io/ldap/superuser' 'ak-superuser' ) SYNTAX '1.3.6.1.4.1.1466.115.121.1.7' SINGLE-VALUE )", "( 1.3.6.1.4.1.26027.1.1.3 NAME ( 'goauthentik.io/ldap/active' 'ak-active' ) SYNTAX '1.3.6.1.4.1.1466.115.121.1.7' SINGLE-VALUE )", - "( 1.3.6.1.4.1.26027.1.1.4 NAME 'goauthentikio-user-override-ips' SYNTAX '1.3.6.1.4.1.1466.115.121.1.7' SINGLE-VALUE )", - "( 1.3.6.1.4.1.26027.1.1.5 NAME 'goauthentikio-user-service-account' SYNTAX '1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE' )", }, }, }, diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 2e55bbde7..08d1ad17c 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-07-11 18:34+0000\n" +"POT-Creation-Date: 2023-07-16 13:59+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -90,133 +90,133 @@ msgstr "" msgid "No empty segments in user path allowed." msgstr "" -#: authentik/core/models.py:74 +#: authentik/core/models.py:86 msgid "name" msgstr "" -#: authentik/core/models.py:76 +#: authentik/core/models.py:88 msgid "Users added to this group will be superusers." msgstr "" -#: authentik/core/models.py:150 +#: authentik/core/models.py:162 msgid "User's display name." msgstr "" -#: authentik/core/models.py:243 authentik/providers/oauth2/models.py:294 +#: authentik/core/models.py:256 authentik/providers/oauth2/models.py:294 msgid "User" msgstr "" -#: authentik/core/models.py:244 +#: authentik/core/models.py:257 msgid "Users" msgstr "" -#: authentik/core/models.py:257 +#: authentik/core/models.py:270 msgid "" "Flow used for authentication when the associated application is accessed by " "an un-authenticated user." msgstr "" -#: authentik/core/models.py:267 +#: authentik/core/models.py:280 msgid "Flow used when authorizing this provider." msgstr "" -#: authentik/core/models.py:279 +#: authentik/core/models.py:292 msgid "" "Accessed from applications; optional backchannel providers for protocols " "like LDAP and SCIM." msgstr "" -#: authentik/core/models.py:334 +#: authentik/core/models.py:347 msgid "Application's display Name." msgstr "" -#: authentik/core/models.py:335 +#: authentik/core/models.py:348 msgid "Internal application name, used in URLs." msgstr "" -#: authentik/core/models.py:347 +#: authentik/core/models.py:360 msgid "Open launch URL in a new browser tab or window." msgstr "" -#: authentik/core/models.py:411 +#: authentik/core/models.py:424 msgid "Application" msgstr "" -#: authentik/core/models.py:412 +#: authentik/core/models.py:425 msgid "Applications" msgstr "" -#: authentik/core/models.py:418 +#: authentik/core/models.py:431 msgid "Use the source-specific identifier" msgstr "" -#: authentik/core/models.py:420 +#: authentik/core/models.py:433 msgid "" "Link to a user with identical email address. Can have security implications " "when a source doesn't validate email addresses." msgstr "" -#: authentik/core/models.py:424 +#: authentik/core/models.py:437 msgid "" "Use the user's email address, but deny enrollment when the email address " "already exists." msgstr "" -#: authentik/core/models.py:427 +#: authentik/core/models.py:440 msgid "" "Link to a user with identical username. Can have security implications when " "a username is used with another source." msgstr "" -#: authentik/core/models.py:431 +#: authentik/core/models.py:444 msgid "" "Use the user's username, but deny enrollment when the username already " "exists." msgstr "" -#: authentik/core/models.py:438 +#: authentik/core/models.py:451 msgid "Source's display Name." msgstr "" -#: authentik/core/models.py:439 +#: authentik/core/models.py:452 msgid "Internal source name, used in URLs." msgstr "" -#: authentik/core/models.py:458 +#: authentik/core/models.py:471 msgid "Flow to use when authenticating existing users." msgstr "" -#: authentik/core/models.py:467 +#: authentik/core/models.py:480 msgid "Flow to use when enrolling new users." msgstr "" -#: authentik/core/models.py:475 +#: authentik/core/models.py:488 msgid "" "How the source determines if an existing user should be authenticated or a " "new user enrolled." msgstr "" -#: authentik/core/models.py:647 +#: authentik/core/models.py:660 msgid "Token" msgstr "" -#: authentik/core/models.py:648 +#: authentik/core/models.py:661 msgid "Tokens" msgstr "" -#: authentik/core/models.py:689 +#: authentik/core/models.py:702 msgid "Property Mapping" msgstr "" -#: authentik/core/models.py:690 +#: authentik/core/models.py:703 msgid "Property Mappings" msgstr "" -#: authentik/core/models.py:725 +#: authentik/core/models.py:738 msgid "Authenticated Session" msgstr "" -#: authentik/core/models.py:726 +#: authentik/core/models.py:739 msgid "Authenticated Sessions" msgstr "" @@ -585,65 +585,65 @@ msgstr "" msgid "Invalid kubeconfig" msgstr "" -#: authentik/outposts/models.py:122 +#: authentik/outposts/models.py:121 msgid "" "If enabled, use the local connection. Required Docker socket/Kubernetes " "Integration" msgstr "" -#: authentik/outposts/models.py:152 +#: authentik/outposts/models.py:151 msgid "Outpost Service-Connection" msgstr "" -#: authentik/outposts/models.py:153 +#: authentik/outposts/models.py:152 msgid "Outpost Service-Connections" msgstr "" -#: authentik/outposts/models.py:161 +#: authentik/outposts/models.py:160 msgid "" "Can be in the format of 'unix://' when connecting to a local docker " "daemon, or 'https://:2376' when connecting to a remote system." msgstr "" -#: authentik/outposts/models.py:173 +#: authentik/outposts/models.py:172 msgid "" "CA which the endpoint's Certificate is verified against. Can be left empty " "for no validation." msgstr "" -#: authentik/outposts/models.py:185 +#: authentik/outposts/models.py:184 msgid "" "Certificate/Key used for authentication. Can be left empty for no " "authentication." msgstr "" -#: authentik/outposts/models.py:203 +#: authentik/outposts/models.py:202 msgid "Docker Service-Connection" msgstr "" -#: authentik/outposts/models.py:204 +#: authentik/outposts/models.py:203 msgid "Docker Service-Connections" msgstr "" -#: authentik/outposts/models.py:212 +#: authentik/outposts/models.py:211 msgid "" "Paste your kubeconfig here. authentik will automatically use the currently " "selected context." msgstr "" -#: authentik/outposts/models.py:218 +#: authentik/outposts/models.py:217 msgid "Verify SSL Certificates of the Kubernetes API endpoint" msgstr "" -#: authentik/outposts/models.py:235 +#: authentik/outposts/models.py:234 msgid "Kubernetes Service-Connection" msgstr "" -#: authentik/outposts/models.py:236 +#: authentik/outposts/models.py:235 msgid "Kubernetes Service-Connections" msgstr "" -#: authentik/outposts/models.py:252 +#: authentik/outposts/models.py:251 msgid "" "Select Service-Connection authentik should use to manage this outpost. Leave " "empty if authentik should not handle the deployment." @@ -1373,31 +1373,31 @@ msgstr "" msgid "SAML Property Mappings" msgstr "" -#: authentik/providers/scim/models.py:26 +#: authentik/providers/scim/models.py:20 msgid "Base URL to SCIM requests, usually ends in /v2" msgstr "" -#: authentik/providers/scim/models.py:27 +#: authentik/providers/scim/models.py:21 msgid "Authentication token" msgstr "" -#: authentik/providers/scim/models.py:33 authentik/sources/ldap/models.py:94 +#: authentik/providers/scim/models.py:27 authentik/sources/ldap/models.py:94 msgid "Property mappings used for group creation/updating." msgstr "" -#: authentik/providers/scim/models.py:75 +#: authentik/providers/scim/models.py:60 msgid "SCIM Provider" msgstr "" -#: authentik/providers/scim/models.py:76 +#: authentik/providers/scim/models.py:61 msgid "SCIM Providers" msgstr "" -#: authentik/providers/scim/models.py:96 +#: authentik/providers/scim/models.py:81 msgid "SCIM Mapping" msgstr "" -#: authentik/providers/scim/models.py:97 +#: authentik/providers/scim/models.py:82 msgid "SCIM Mappings" msgstr "" diff --git a/schema.yml b/schema.yml index f85078279..d5d9c5158 100644 --- a/schema.yml +++ b/schema.yml @@ -4604,6 +4604,20 @@ paths: description: A search term. schema: type: string + - in: query + name: type + schema: + type: string + enum: + - default + - external + - internal_service_account + - service_account + description: |- + * `default` - Default + * `external` - External + * `service_account` - Service Account + * `internal_service_account` - Internal Service Account - in: query name: username schema: @@ -4612,6 +4626,7 @@ paths: name: uuid schema: type: string + format: uuid tags: - core security: @@ -5527,6 +5542,356 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /enterprise/license/: + get: + operationId: enterprise_license_list + description: License Viewset + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - enterprise + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedLicenseList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: enterprise_license_create + description: License Viewset + tags: + - enterprise + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LicenseRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/License' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /enterprise/license/{license_uuid}/: + get: + operationId: enterprise_license_retrieve + description: License Viewset + parameters: + - in: path + name: license_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this license. + required: true + tags: + - enterprise + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/License' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: enterprise_license_update + description: License Viewset + parameters: + - in: path + name: license_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this license. + required: true + tags: + - enterprise + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LicenseRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/License' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: enterprise_license_partial_update + description: License Viewset + parameters: + - in: path + name: license_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this license. + required: true + tags: + - enterprise + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedLicenseRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/License' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: enterprise_license_destroy + description: License Viewset + parameters: + - in: path + name: license_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this license. + required: true + tags: + - enterprise + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /enterprise/license/{license_uuid}/used_by/: + get: + operationId: enterprise_license_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: license_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this license. + required: true + tags: + - enterprise + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /enterprise/license/forecast/: + get: + operationId: enterprise_license_forecast_retrieve + description: Forecast how many users will be required in a year + tags: + - enterprise + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LicenseForecast' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /enterprise/license/get_install_id/: + get: + operationId: enterprise_license_get_install_id_retrieve + description: Get install_id + tags: + - enterprise + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/InstallID' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /enterprise/license/summary/: + get: + operationId: enterprise_license_summary_retrieve + description: Get the total license status + tags: + - enterprise + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LicenseSummary' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /events/events/: get: operationId: events_events_list @@ -30468,6 +30833,13 @@ components: type: boolean required: - name + InstallID: + type: object + properties: + install_id: + type: string + required: + - install_id IntentEnum: enum: - verification @@ -31336,6 +31708,86 @@ components: * `content_right` - CONTENT_RIGHT * `sidebar_left` - SIDEBAR_LEFT * `sidebar_right` - SIDEBAR_RIGHT + License: + type: object + description: License Serializer + properties: + license_uuid: + type: string + format: uuid + readOnly: true + name: + type: string + readOnly: true + key: + type: string + expiry: + type: string + format: date-time + readOnly: true + users: + type: integer + readOnly: true + external_users: + type: integer + readOnly: true + required: + - expiry + - external_users + - key + - license_uuid + - name + - users + LicenseForecast: + type: object + description: Serializer for license forecast + properties: + users: + type: integer + external_users: + type: integer + required: + - external_users + - users + LicenseRequest: + type: object + description: License Serializer + properties: + key: + type: string + minLength: 1 + required: + - key + LicenseSummary: + type: object + description: Serializer for license status + properties: + users: + type: integer + external_users: + type: integer + valid: + type: boolean + show_admin_warning: + type: boolean + show_user_warning: + type: boolean + read_only: + type: boolean + latest_valid: + type: string + format: date-time + has_license: + type: boolean + required: + - external_users + - has_license + - latest_valid + - read_only + - show_admin_warning + - show_user_warning + - users + - valid Link: type: object description: Returns a single link @@ -33686,6 +34138,41 @@ components: required: - pagination - results + PaginatedLicenseList: + type: object + properties: + pagination: + type: object + properties: + next: + type: number + previous: + type: number + count: + type: number + current: + type: number + total_pages: + type: number + start_index: + type: number + end_index: + type: number + required: + - next + - previous + - count + - current + - total_pages + - start_index + - end_index + results: + type: array + items: + $ref: '#/components/schemas/License' + required: + - pagination + - results PaginatedNotificationList: type: object properties: @@ -36850,6 +37337,13 @@ components: type: string format: uuid description: Property mappings used for group creation/updating. + PatchedLicenseRequest: + type: object + description: License Serializer + properties: + key: + type: string + minLength: 1 PatchedNotificationRequest: type: object description: Notification Serializer @@ -38033,6 +38527,8 @@ components: path: type: string minLength: 1 + type: + $ref: '#/components/schemas/UserTypeEnum' PatchedUserSAMLSourceConnectionRequest: type: object description: SAML Source Serializer @@ -41430,6 +41926,8 @@ components: readOnly: true path: type: string + type: + $ref: '#/components/schemas/UserTypeEnum' required: - avatar - groups_obj @@ -41888,6 +42386,8 @@ components: path: type: string minLength: 1 + type: + $ref: '#/components/schemas/UserTypeEnum' required: - name - username @@ -41971,6 +42471,8 @@ components: additionalProperties: {} description: Get user settings with tenant and group settings applied readOnly: true + type: + $ref: '#/components/schemas/UserTypeEnum' required: - avatar - groups @@ -42071,6 +42573,18 @@ components: - pk - source - user + UserTypeEnum: + enum: + - default + - external + - service_account + - internal_service_account + type: string + description: |- + * `default` - Default + * `external` - External + * `service_account` - Service Account + * `internal_service_account` - Internal Service Account UserVerificationEnum: enum: - required diff --git a/tests/e2e/test_provider_ldap.py b/tests/e2e/test_provider_ldap.py index cb709c30b..02564e4bd 100644 --- a/tests/e2e/test_provider_ldap.py +++ b/tests/e2e/test_provider_ldap.py @@ -256,8 +256,6 @@ class TestProviderLDAP(SeleniumTestCase): "homeDirectory": f"/home/{o_user.username}", "ak-active": True, "ak-superuser": False, - "goauthentikio-user-override-ips": True, - "goauthentikio-user-service-account": True, }, "type": "searchResEntry", }, @@ -284,8 +282,6 @@ class TestProviderLDAP(SeleniumTestCase): "homeDirectory": f"/home/{embedded_account.username}", "ak-active": True, "ak-superuser": False, - "goauthentikio-user-override-ips": True, - "goauthentikio-user-service-account": True, }, "type": "searchResEntry", }, diff --git a/web/.prettierignore b/web/.prettierignore index 8fc79c97c..0e7baef47 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -8,3 +8,4 @@ coverage poly.ts src/locale-codes.ts src/locales/ +storybook-static/ diff --git a/web/src/admin/AdminInterface.ts b/web/src/admin/AdminInterface.ts index d41af2692..c59809c13 100644 --- a/web/src/admin/AdminInterface.ts +++ b/web/src/admin/AdminInterface.ts @@ -11,6 +11,7 @@ import { me } from "@goauthentik/common/users"; import { WebsocketClient } from "@goauthentik/common/ws"; import { Interface } from "@goauthentik/elements/Base"; import "@goauthentik/elements/ak-locale-context"; +import "@goauthentik/elements/enterprise/EnterpriseStatusBanner"; import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/notifications/APIDrawer"; @@ -30,7 +31,14 @@ import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { AdminApi, CoreApi, SessionUser, UiThemeEnum, Version } from "@goauthentik/api"; +import { + AdminApi, + CapabilitiesEnum, + CoreApi, + SessionUser, + UiThemeEnum, + Version, +} from "@goauthentik/api"; @customElement("ak-interface-admin") export class AdminInterface extends Interface { @@ -67,7 +75,17 @@ export class AdminInterface extends Interface { .display-none { display: none; } + :host { + display: flex; + flex-direction: column; + height: 100%; + } + ak-locale-context { + display: flex; + flex-grow: 1; + } .pf-c-page { + flex-grow: 1; background-color: var(--pf-c-page--BackgroundColor) !important; } /* Global page background colour */ @@ -113,7 +131,8 @@ export class AdminInterface extends Interface { render(): TemplateResult { return html`
+ > +
${msg("Outpost Integrations")} + ${this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise) + ? html` + + ${msg("Enterprise")} + + ${msg("Licenses")} + + + ` + : html``} `; } } diff --git a/web/src/admin/Routes.ts b/web/src/admin/Routes.ts index bb695cfa5..55a830835 100644 --- a/web/src/admin/Routes.ts +++ b/web/src/admin/Routes.ts @@ -136,4 +136,8 @@ export const ROUTES: Route[] = [ await import("@goauthentik/admin/DebugPage"); return html``; }), + new Route(new RegExp("^/enterprise/licenses$"), async () => { + await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage"); + return html``; + }), ]; diff --git a/web/src/admin/admin-overview/cards/SystemStatusCard.ts b/web/src/admin/admin-overview/cards/SystemStatusCard.ts index 42590d1e4..eee0bca32 100644 --- a/web/src/admin/admin-overview/cards/SystemStatusCard.ts +++ b/web/src/admin/admin-overview/cards/SystemStatusCard.ts @@ -26,6 +26,7 @@ export class SystemStatusCard extends AdminStatusCard { // First install, ensure the embedded outpost host is set // also run when outpost host does not contain http // (yes it's called host and requires a URL, i know) + // TODO: Improve this in OOB flow await this.setOutpostHost(); status = await new AdminApi(DEFAULT_CONFIG).adminSystemRetrieve(); } diff --git a/web/src/admin/enterprise/EnterpriseLicenseForm.ts b/web/src/admin/enterprise/EnterpriseLicenseForm.ts new file mode 100644 index 000000000..45eceae75 --- /dev/null +++ b/web/src/admin/enterprise/EnterpriseLicenseForm.ts @@ -0,0 +1,64 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { EnterpriseApi, License } from "@goauthentik/api"; + +@customElement("ak-enterprise-license-form") +export class EnterpriseLicenseForm extends ModelForm { + @state() + installID?: string; + + loadInstance(pk: string): Promise { + return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseRetrieve({ + licenseUuid: pk, + }); + } + + getSuccessMessage(): string { + if (this.instance) { + return msg("Successfully updated license."); + } else { + return msg("Successfully created license."); + } + } + + async load(): Promise { + this.installID = ( + await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseGetInstallIdRetrieve() + ).installId; + } + + async send(data: License): Promise { + if (this.instance) { + return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicensePartialUpdate({ + licenseUuid: this.instance.licenseUuid || "", + patchedLicenseRequest: data, + }); + } else { + return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseCreate({ + licenseRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html`
+ + + + + + +
`; + } +} diff --git a/web/src/admin/enterprise/EnterpriseLicenseListPage.ts b/web/src/admin/enterprise/EnterpriseLicenseListPage.ts new file mode 100644 index 000000000..b3c555098 --- /dev/null +++ b/web/src/admin/enterprise/EnterpriseLicenseListPage.ts @@ -0,0 +1,222 @@ +import "@goauthentik/admin/enterprise/EnterpriseLicenseForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { uiConfig } from "@goauthentik/common/ui/config"; +import { PFColor } from "@goauthentik/elements/Label"; +import "@goauthentik/elements/Spinner"; +import "@goauthentik/elements/buttons/SpinnerButton"; +import "@goauthentik/elements/cards/AggregateCard"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import { TableColumn } from "@goauthentik/elements/table/Table"; +import { TablePage } from "@goauthentik/elements/table/TablePage"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; + +import { EnterpriseApi, License, LicenseForecast, LicenseSummary } from "@goauthentik/api"; + +@customElement("ak-enterprise-license-list") +export class EnterpriseLicenseListPage extends TablePage { + checkbox = true; + + searchEnabled(): boolean { + return true; + } + pageTitle(): string { + return msg("Licenses"); + } + pageDescription(): string { + return msg("Manage enterprise licenses"); + } + pageIcon(): string { + return "pf-icon pf-icon-key"; + } + + @property() + order = "name"; + + @state() + forecast?: LicenseForecast; + + @state() + summary?: LicenseSummary; + + @state() + installID?: string; + + static get styles(): CSSResult[] { + return super.styles.concat( + PFDescriptionList, + PFGrid, + PFBanner, + PFFormControl, + PFButton, + PFCard, + css` + .pf-m-no-padding-bottom { + padding-bottom: 0; + } + `, + ); + } + + async apiEndpoint(page: number): Promise> { + this.forecast = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseForecastRetrieve(); + this.summary = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve(); + this.installID = ( + await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseGetInstallIdRetrieve() + ).installId; + return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn(msg("Name"), "name"), + new TableColumn(msg("Users")), + new TableColumn(msg("Expiry date")), + new TableColumn(msg("Actions")), + ]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return [ + { key: msg("Name"), value: item.name }, + { key: msg("Expiry"), value: item.expiry?.toLocaleString() }, + ]; + }} + .usedBy=${(item: License) => { + return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseUsedByList({ + licenseUuid: item.licenseUuid, + }); + }} + .delete=${(item: License) => { + return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseDestroy({ + licenseUuid: item.licenseUuid, + }); + }} + > + + `; + } + + renderSectionBefore(): TemplateResult { + return html` +
+ ${msg("Enterprise is in preview.")} + ${msg("Send us feedback!")} +
+
+
+
+
${msg("How to get a license")}
+
+ ${this.installID + ? html` ${msg("Go to the customer portal")}` + : html``} +
+
+
+ + ${this.forecast?.users} + +
+
+ + ${this.forecast?.externalUsers} + +
+
+ + ${this.summary?.hasLicense + ? this.summary.latestValid.toLocaleString() + : "-"} + +
+
+
+ `; + } + + row(item: License): TemplateResult[] { + let color = PFColor.Green; + if (item.expiry) { + const now = new Date(); + const inAMonth = new Date(); + inAMonth.setDate(inAMonth.getDate() + 30); + if (item.expiry <= inAMonth) { + color = PFColor.Orange; + } + if (item.expiry <= now) { + color = PFColor.Red; + } + } + return [ + html`
${item.name}
`, + html`
+ 0 / ${item.users} + 0 / ${item.externalUsers} +
`, + html` ${item.expiry?.toLocaleString()} `, + html` + ${msg("Update")} + ${msg("Update License")} + + + + `, + ]; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Create")} + ${msg("Create License")} + + + + `; + } +} diff --git a/web/src/admin/users/UserForm.ts b/web/src/admin/users/UserForm.ts index 1307052c9..55dc52f58 100644 --- a/web/src/admin/users/UserForm.ts +++ b/web/src/admin/users/UserForm.ts @@ -1,9 +1,11 @@ import "@goauthentik/admin/users/GroupSelectModal"; +import { UserTypeEnum } from "@goauthentik/api/dist/models/UserTypeEnum"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import "@goauthentik/elements/forms/Radio"; import YAML from "yaml"; import { msg } from "@lit/localize"; @@ -75,6 +77,31 @@ export class UserForm extends ModelForm { />

${msg("User's display name.")}

+ + + +
${this.renderInner()} + ${this.subtext ? html`

${this.subtext}

` : html``}
`; } diff --git a/web/src/elements/enterprise/EnterpriseStatusBanner.ts b/web/src/elements/enterprise/EnterpriseStatusBanner.ts new file mode 100644 index 000000000..fddf29990 --- /dev/null +++ b/web/src/elements/enterprise/EnterpriseStatusBanner.ts @@ -0,0 +1,52 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { AKElement } from "@goauthentik/elements/Base"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; + +import { EnterpriseApi, LicenseSummary } from "@goauthentik/api"; + +@customElement("ak-enterprise-status") +export class EnterpriseStatusBanner extends AKElement { + @state() + summary?: LicenseSummary; + + @property() + interface: "admin" | "user" | "" = ""; + + static get styles(): CSSResult[] { + return [PFBanner]; + } + + firstUpdated(): void { + new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve().then((b) => { + this.summary = b; + }); + } + + renderBanner(): TemplateResult { + return html`
+ ${msg("Warning: The current user count has exceeded the configured licenses.")} + ${msg("Click here for more info.")} +
`; + } + + render(): TemplateResult { + switch (this.interface.toLowerCase()) { + case "admin": + if (this.summary?.showAdminWarning || this.summary?.readOnly) { + return this.renderBanner(); + } + break; + case "user": + if (this.summary?.showUserWarning || this.summary?.readOnly) { + return this.renderBanner(); + } + break; + } + return html``; + } +} diff --git a/web/src/elements/table/TablePage.ts b/web/src/elements/table/TablePage.ts index 9d7bdd77e..8ca297fe6 100644 --- a/web/src/elements/table/TablePage.ts +++ b/web/src/elements/table/TablePage.ts @@ -28,6 +28,16 @@ export abstract class TablePage extends Table { return html``; } + // Optionally render section above the table + renderSectionBefore(): TemplateResult { + return html``; + } + + // Optionally render section below the table + renderSectionAfter(): TemplateResult { + return html``; + } + renderEmpty(inner?: TemplateResult): TemplateResult { return super.renderEmpty(html` ${inner @@ -75,6 +85,7 @@ export abstract class TablePage extends Table { description=${ifDefined(this.pageDescription())} > + ${this.renderSectionBefore()}
@@ -85,6 +96,7 @@ export abstract class TablePage extends Table { ${this.renderSidebarAfter()}
-
`; + + ${this.renderSectionAfter()}`; } } diff --git a/web/src/user/UserInterface.ts b/web/src/user/UserInterface.ts index acd72e75f..8986bc8c2 100644 --- a/web/src/user/UserInterface.ts +++ b/web/src/user/UserInterface.ts @@ -11,6 +11,8 @@ import { first } from "@goauthentik/common/utils"; import { WebsocketClient } from "@goauthentik/common/ws"; import { Interface } from "@goauthentik/elements/Base"; import "@goauthentik/elements/ak-locale-context"; +import "@goauthentik/elements/buttons/ActionButton"; +import "@goauthentik/elements/enterprise/EnterpriseStatusBanner"; import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/notifications/APIDrawer"; import "@goauthentik/elements/notifications/NotificationDrawer"; @@ -35,7 +37,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; -import { EventsApi, SessionUser } from "@goauthentik/api"; +import { CoreApi, EventsApi, SessionUser } from "@goauthentik/api"; @customElement("ak-interface-user") export class UserInterface extends Interface { @@ -148,6 +150,7 @@ export class UserInterface extends Interface { userDisplay = this.me.user.username; } return html` +
@@ -243,18 +246,23 @@ export class UserInterface extends Interface { : html``}
${this.me.original - ? html`` + ? html`  +
+
+ { + return new CoreApi(DEFAULT_CONFIG) + .coreUsersImpersonateEndRetrieve() + .then(() => { + window.location.reload(); + }); + }} + > + ${msg("Stop impersonation")} + +
+
` : html``}
When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/en.xlf b/web/xliff/en.xlf index 8832f00fb..913b11bed 100644 --- a/web/xliff/en.xlf +++ b/web/xliff/en.xlf @@ -6063,6 +6063,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index 972e7a147..924719034 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -5655,6 +5655,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/fr_FR.xlf b/web/xliff/fr_FR.xlf index f095b0459..4089c3054 100644 --- a/web/xliff/fr_FR.xlf +++ b/web/xliff/fr_FR.xlf @@ -5762,6 +5762,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/pl.xlf b/web/xliff/pl.xlf index 29646ec36..501b84c43 100644 --- a/web/xliff/pl.xlf +++ b/web/xliff/pl.xlf @@ -5894,6 +5894,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/pseudo-LOCALE.xlf b/web/xliff/pseudo-LOCALE.xlf index 97bd3abef..ac4e90231 100644 --- a/web/xliff/pseudo-LOCALE.xlf +++ b/web/xliff/pseudo-LOCALE.xlf @@ -5998,6 +5998,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/tr.xlf b/web/xliff/tr.xlf index 15f95e098..2f7a31388 100644 --- a/web/xliff/tr.xlf +++ b/web/xliff/tr.xlf @@ -5645,6 +5645,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/zh-Hans.xlf b/web/xliff/zh-Hans.xlf index 79145d376..57ff1e4fa 100644 --- a/web/xliff/zh-Hans.xlf +++ b/web/xliff/zh-Hans.xlf @@ -7580,6 +7580,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. 启用时,可以通过在密码后添加分号和 TOTP 代码来使用基于代码的多因素身份验证。仅在所有绑定到此提供程序的用户都已配置 TOTP 设备的情况下才应该启用,否则密码可能会因为包含分号而被错误地拒绝。 + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/zh-Hant.xlf b/web/xliff/zh-Hant.xlf index d4deb9e1c..387bf36fd 100644 --- a/web/xliff/zh-Hant.xlf +++ b/web/xliff/zh-Hant.xlf @@ -5700,6 +5700,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses diff --git a/web/xliff/zh_TW.xlf b/web/xliff/zh_TW.xlf index 1111b9d13..577397538 100644 --- a/web/xliff/zh_TW.xlf +++ b/web/xliff/zh_TW.xlf @@ -5699,6 +5699,84 @@ Bindings to groups/users are checked against the user of the event. When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + + + User type + + + Default user + + + External user + + + Service account + + + Successfully updated license. + + + Successfully created license. + + + Install ID + + + License key + + + Licenses + + + TODO Copy + + + License(s) + + + Enterprise is in preview. + + + How to get a license + + + Copy the installation ID + + + Then open the customer portal + + + Forecasted default users + + + Estimated user count one year from now + + + Forecasted external users + + + Estimated external user count one year from now + + + Cumulative license expiry + + + Update License + + + Create License + + + Warning: The current user count has exceeded the configured licenses. + + + Click here for more info. + + + Enterprise + + + Manage enterprise licenses