Compare commits

...

10 Commits

Author SHA1 Message Date
0edd7531a1 release: 2024.10.5 2024-12-10 17:48:12 +01:00
5a2c914d19 flows: better test stage's challenge responses (cherry-pick #12316) (#12317)
flows: better test stage's challenge responses (#12316)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-12-10 17:47:19 +01:00
f21062581a ci: build release test image with no cache
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-12-10 17:45:48 +01:00
676e7885e8 root: lock setuptools to prevent docker install issue
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-12-10 17:12:52 +01:00
80441d2277 enterprise/stages/authenticator_endpoint_gdtc: don't set frame options globally (cherry-pick #12311) (#12315)
enterprise/stages/authenticator_endpoint_gdtc: don't set frame options globally (#12311)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-12-10 15:22:47 +01:00
e760f73518 stages/identification: fix invalid challenge warning when no captcha stage is set (cherry-pick #12312) (#12314)
stages/identification: fix invalid challenge warning when no captcha stage is set (#12312)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-12-10 15:22:38 +01:00
948f80d7ae enterprise: allow deletion/modification of users when in read-only mode (#12289)
* enterprise: allow deletion/modification of users when in read-only mode

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* actually 10.5+

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Apply suggestions from code review

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Jens L. <jens@beryju.org>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	website/docs/enterprise/manage-enterprise.md
2024-12-10 13:08:59 +01:00
0e4b153e7f web/flows: resize captcha iframes (cherry-pick #12260) (#12304)
web/flows: resize captcha iframes (#12260)

* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* web: streamline CaptchaStage

# What

This commit:

1. Replaces the mass of `if () { if() { if() } }` with two state tables:
  - One for `render()`
  - One for `renderBody()`

2. Breaks each Captcha out into "interactive" and "executive" versions
3. Creates a handler table for each Captcha type
4. Replaces the `.forEach` expression with a `for` loop.
5. Move `updated` to the end of the class.
6. Make captchDocument and captchaFrame constructed-on-demand with a cache.
7. Remove a lot of `@state` handlers
8. Give IframeMessageEvent its own type.
9. Removed `this.scriptElement`
10. Replaced `window.removeEventListener` with an `AbortController()`
# Why

1. **Replacing `if` trees with a state table.** The logic of the original was really hard to follow.
   With the state table, we can clearly see now that for the `render()` function, we care about the
   Boolean flags `[embedded, challenged, interactive]` and have appropriate effects for each. With
   `renderBody()`, we can see that we care about the Boolean flags `[hasError, challenged]`, and can
   see the appropriate effects for each one.

2. (and 3.) **Breaking each Captcha clause into separate handlers.** Again, the logic was convoluted,
   when what we really care about is "Does this captcha have a corresponding handler attached to
   `window`, and, if so, should we run the interactive or executive version of it?" By putting all
   of that into a table of `[name, challenge, execute]`, we can clearly see what's being handled
   when.

4. **Replacing `foreach()` with `for()`**: [You cannot use a `forEach()` with async
   expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#:~:text=does%20not%20wait%20for%20promises).
   If you need asynchronous behavior in an ordered loop, `for()` is the safest way to handle it; if
   you need asynchronous behavior from multiple promises, `Promise.allSettled(handlers.map())` is
   the way to go.

   I tried to tell if this function *meant* to run every handler it found simultaneously, or if it
   meant to test them in order; I went with the second option, breaking and exiting the loop once a
   handler had run successfully.

5. **Reordered the code a bit**. We're trying to evolve a pattern in our source code: styles,
   properties, states, internal variables, constructor, getters & setters that are not `@property()`
   or `@state()`, DOM-related lifecycle handlers, event handlers, pre-render lifecycle handlers,
   renderers, and post-render lifecycle handlers. Helper methods (including subrenderers) go above
   the method(s) they help.

6. **Constructing Elements on demand with a cache**. It is not guaranteed that we will actually need
   either of those. Constructing them on demand with a cache is both performant and cleaner.
   Likewise, I removed these from the Lit object's `state()` table, since they're constructed once
   and never change over the lifetime of an instance of `ak-stage-captcha`.

9. **Remove `this.scriptElement`**: It was never referenced outside the one function where it was used.

10. **Remove `removeEventListener()`**: `AbortController()` is a bit more verbose for small event
    handler collections, but it's considered much more reliable and much cleaner.

* Didn't save the extracted ListenerController.

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2024-12-10 00:28:51 +01:00
efac5ce7bd web: backport fix impersonate api (#12184)
web/admin: fix impersonate API call

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-11-25 17:04:56 +01:00
d9fbe1d467 root: fix database ssl options not set correctly (cherry-pick #12180) (#12183)
root: fix database ssl options not set correctly (#12180)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-11-25 14:56:30 +01:00
25 changed files with 428 additions and 224 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2024.10.4 current_version = 2024.10.5
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?

View File

@ -18,7 +18,7 @@ jobs:
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
docker buildx install docker buildx install
mkdir -p ./gen-ts-api mkdir -p ./gen-ts-api
docker build -t testing:latest . docker build --no-cache -t testing:latest .
echo "AUTHENTIK_IMAGE=testing" >> .env echo "AUTHENTIK_IMAGE=testing" >> .env
echo "AUTHENTIK_TAG=latest" >> .env echo "AUTHENTIK_TAG=latest" >> .env
docker compose up --no-start docker compose up --no-start

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2024.10.4" __version__ = "2024.10.5"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -6,6 +6,7 @@ from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import resolve from django.urls import resolve
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.core.api.users import UserViewSet
from authentik.enterprise.api import LicenseViewSet from authentik.enterprise.api import LicenseViewSet
from authentik.enterprise.license import LicenseKey from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsageStatus from authentik.enterprise.models import LicenseUsageStatus
@ -59,6 +60,9 @@ class EnterpriseMiddleware:
# Flow executor is mounted as an API path but explicitly allowed # Flow executor is mounted as an API path but explicitly allowed
if request.resolver_match._func_path == class_to_path(FlowExecutorView): if request.resolver_match._func_path == class_to_path(FlowExecutorView):
return True return True
# Always allow making changes to users, even in case the license has ben exceeded
if request.resolver_match._func_path == class_to_path(UserViewSet):
return True
# Only apply these restrictions to the API # Only apply these restrictions to the API
if "authentik_api" not in request.resolver_match.app_names: if "authentik_api" not in request.resolver_match.app_names:
return True return True

View File

@ -4,7 +4,9 @@ from typing import Any
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.clickjacking import xframe_options_sameorigin
from googleapiclient.discovery import build from googleapiclient.discovery import build
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
@ -26,6 +28,7 @@ HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess" DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
@method_decorator(xframe_options_sameorigin, name="dispatch")
class GoogleChromeDeviceTrustConnector(View): class GoogleChromeDeviceTrustConnector(View):
"""Google Chrome Device-trust connector based endpoint authenticator""" """Google Chrome Device-trust connector based endpoint authenticator"""

View File

@ -215,3 +215,49 @@ class TestReadOnly(FlowTestCase):
{"detail": "Request denied due to expired/invalid license.", "code": "denied_license"}, {"detail": "Request denied due to expired/invalid license.", "code": "denied_license"},
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@patch(
"authentik.enterprise.license.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=expiry_valid,
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
@patch(
"authentik.enterprise.license.LicenseKey.get_internal_user_count",
MagicMock(return_value=1000),
)
@patch(
"authentik.enterprise.license.LicenseKey.get_external_user_count",
MagicMock(return_value=1000),
)
@patch(
"authentik.enterprise.license.LicenseKey.record_usage",
MagicMock(),
)
def test_manage_users(self):
"""Test that managing users is still possible"""
License.objects.create(key=generate_id())
usage = LicenseUsage.objects.create(
internal_user_count=100,
external_user_count=100,
status=LicenseUsageStatus.VALID,
)
usage.record_date = now() - timedelta(weeks=THRESHOLD_READ_ONLY_WEEKS + 1)
usage.save(update_fields=["record_date"])
admin = create_test_admin_user()
self.client.force_login(admin)
# Reading is always allowed
response = self.client.get(reverse("authentik_api:user-list"))
self.assertEqual(response.status_code, 200)
# Writing should also be allowed
response = self.client.patch(reverse("authentik_api:user-detail", kwargs={"pk": admin.pk}))
self.assertEqual(response.status_code, 200)

View File

@ -2,6 +2,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest from django.http import HttpRequest
from django.http.request import QueryDict from django.http.request import QueryDict
@ -224,6 +225,14 @@ class ChallengeStageView(StageView):
full_errors[field].append(field_error) full_errors[field].append(field_error)
challenge_response.initial_data["response_errors"] = full_errors challenge_response.initial_data["response_errors"] = full_errors
if not challenge_response.is_valid(): if not challenge_response.is_valid():
if settings.TEST:
raise StageInvalidException(
(
f"Invalid challenge response: \n\t{challenge_response.errors}"
f"\n\nValidated data:\n\t {challenge_response.data}"
f"\n\nInitial data:\n\t {challenge_response.initial_data}"
),
)
self.logger.error( self.logger.error(
"f(ch): invalid challenge response", "f(ch): invalid challenge response",
errors=challenge_response.errors, errors=challenge_response.errors,

View File

@ -38,7 +38,6 @@ LANGUAGE_COOKIE_NAME = "authentik_language"
SESSION_COOKIE_NAME = "authentik_session" SESSION_COOKIE_NAME = "authentik_session"
SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None) SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
APPEND_SLASH = False APPEND_SLASH = False
X_FRAME_OPTIONS = "SAMEORIGIN"
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
@ -304,10 +303,12 @@ DATABASES = {
"USER": CONFIG.get("postgresql.user"), "USER": CONFIG.get("postgresql.user"),
"PASSWORD": CONFIG.get("postgresql.password"), "PASSWORD": CONFIG.get("postgresql.password"),
"PORT": CONFIG.get("postgresql.port"), "PORT": CONFIG.get("postgresql.port"),
"SSLMODE": CONFIG.get("postgresql.sslmode"), "OPTIONS": {
"SSLROOTCERT": CONFIG.get("postgresql.sslrootcert"), "sslmode": CONFIG.get("postgresql.sslmode"),
"SSLCERT": CONFIG.get("postgresql.sslcert"), "sslrootcert": CONFIG.get("postgresql.sslrootcert"),
"SSLKEY": CONFIG.get("postgresql.sslkey"), "sslcert": CONFIG.get("postgresql.sslcert"),
"sslkey": CONFIG.get("postgresql.sslkey"),
},
"TEST": { "TEST": {
"NAME": CONFIG.get("postgresql.test.name"), "NAME": CONFIG.get("postgresql.test.name"),
}, },

View File

@ -332,7 +332,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
serializer = SelectableStageSerializer( serializer = SelectableStageSerializer(
data={ data={
"pk": stage.pk, "pk": stage.pk,
"name": getattr(stage, "friendly_name", stage.name), "name": getattr(stage, "friendly_name", stage.name) or stage.name,
"verbose_name": str(stage._meta.verbose_name) "verbose_name": str(stage._meta.verbose_name)
.replace("Setup Stage", "") .replace("Setup Stage", "")
.strip(), .strip(),

View File

@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls.base import reverse from django.urls.base import reverse
from django.utils.timezone import now
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
@ -13,6 +14,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDigits
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
@ -76,8 +78,8 @@ class AuthenticatorValidateStageTests(FlowTestCase):
conf_stage = AuthenticatorStaticStage.objects.create( conf_stage = AuthenticatorStaticStage.objects.create(
name=generate_id(), name=generate_id(),
) )
conf_stage2 = AuthenticatorStaticStage.objects.create( conf_stage2 = AuthenticatorTOTPStage.objects.create(
name=generate_id(), name=generate_id(), digits=TOTPDigits.SIX
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name=generate_id(), name=generate_id(),
@ -153,10 +155,14 @@ class AuthenticatorValidateStageTests(FlowTestCase):
{ {
"device_class": "static", "device_class": "static",
"device_uid": "1", "device_uid": "1",
"challenge": {},
"last_used": now(),
}, },
{ {
"device_class": "totp", "device_class": "totp",
"device_uid": "2", "device_uid": "2",
"challenge": {},
"last_used": now(),
}, },
] ]
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan

View File

@ -26,6 +26,7 @@ from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET
from authentik.lib.avatars import DEFAULT_AVATAR
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
from authentik.lib.utils.urls import reverse_with_qs from authentik.lib.utils.urls import reverse_with_qs
from authentik.root.middleware import ClientIPMiddleware from authentik.root.middleware import ClientIPMiddleware
@ -76,7 +77,7 @@ class IdentificationChallenge(Challenge):
allow_show_password = BooleanField(default=False) allow_show_password = BooleanField(default=False)
application_pre = CharField(required=False) application_pre = CharField(required=False)
flow_designation = ChoiceField(FlowDesignation.choices) flow_designation = ChoiceField(FlowDesignation.choices)
captcha_stage = CaptchaChallenge(required=False) captcha_stage = CaptchaChallenge(required=False, allow_null=True)
enroll_url = CharField(required=False) enroll_url = CharField(required=False)
recovery_url = CharField(required=False) recovery_url = CharField(required=False)
@ -224,6 +225,8 @@ class IdentificationStageView(ChallengeStageView):
"js_url": current_stage.captcha_stage.js_url, "js_url": current_stage.captcha_stage.js_url,
"site_key": current_stage.captcha_stage.public_key, "site_key": current_stage.captcha_stage.public_key,
"interactive": current_stage.captcha_stage.interactive, "interactive": current_stage.captcha_stage.interactive,
"pending_user": "",
"pending_user_avatar": DEFAULT_AVATAR,
} }
if current_stage.captcha_stage if current_stage.captcha_stage
else None else None

View File

@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema", "$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json", "$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object", "type": "object",
"title": "authentik 2024.10.4 Blueprint schema", "title": "authentik 2024.10.5 Blueprint schema",
"required": [ "required": [
"version", "version",
"entries" "entries"

View File

@ -31,7 +31,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.4} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.5}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -52,7 +52,7 @@ services:
- postgresql - postgresql
- redis - redis
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.4} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.5}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

View File

@ -29,4 +29,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion()) return fmt.Sprintf("authentik@%s", FullVersion())
} }
const VERSION = "2024.10.4" const VERSION = "2024.10.5"

View File

@ -1,5 +1,5 @@
{ {
"name": "@goauthentik/authentik", "name": "@goauthentik/authentik",
"version": "2024.10.4", "version": "2024.10.5",
"private": true "private": true
} }

16
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
@ -4549,19 +4549,19 @@ test = ["pytest"]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "72.1.0" version = "69.1.1"
description = "Easily download, build, install, upgrade, and uninstall Python packages" description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"}, {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"},
{file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"}, {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"},
] ]
[package.extras] [package.extras]
core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]] [[package]]
name = "six" name = "six"
@ -5565,4 +5565,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "~3.12" python-versions = "~3.12"
content-hash = "10aa88f2f0e56cddd91adba8c39c52de92763429fb615a27c3dc218952cff808" content-hash = "32f3901cb944de57ed5cb11dde3a2010de845b04adf557b7e3a701581e260613"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "authentik" name = "authentik"
version = "2024.10.4" version = "2024.10.5"
description = "" description = ""
authors = ["authentik Team <hello@goauthentik.io>"] authors = ["authentik Team <hello@goauthentik.io>"]
@ -139,6 +139,7 @@ scim2-filter-parser = "*"
sentry-sdk = "*" sentry-sdk = "*"
service_identity = "*" service_identity = "*"
setproctitle = "*" setproctitle = "*"
setuptools = "~69.1"
structlog = "*" structlog = "*"
swagger-spec-validator = "*" swagger-spec-validator = "*"
tenant-schemas-celery = "*" tenant-schemas-celery = "*"

View File

@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: authentik title: authentik
version: 2024.10.4 version: 2024.10.5
description: Making authentication simple. description: Making authentication simple.
contact: contact:
email: hello@goauthentik.io email: hello@goauthentik.io

View File

@ -219,6 +219,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
return new CoreApi(DEFAULT_CONFIG) return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({ .coreUsersImpersonateCreate({
id: item.pk, id: item.pk,
impersonationRequest: { reason: "" },
}) })
.then(() => { .then(() => {
window.location.href = "/"; window.location.href = "/";

View File

@ -272,6 +272,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
return new CoreApi(DEFAULT_CONFIG) return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({ .coreUsersImpersonateCreate({
id: item.pk, id: item.pk,
impersonationRequest: { reason: "" },
}) })
.then(() => { .then(() => {
window.location.href = "/"; window.location.href = "/";

View File

@ -215,6 +215,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
return new CoreApi(DEFAULT_CONFIG) return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({ .coreUsersImpersonateCreate({
id: user.pk, id: user.pk,
impersonationRequest: { reason: "" },
}) })
.then(() => { .then(() => {
window.location.href = "/"; window.location.href = "/";

View File

@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger"; export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress"; export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current"; export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2024.10.4"; export const VERSION = "2024.10.5";
export const TITLE_DEFAULT = "authentik"; export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";"; export const ROUTE_SEPARATOR = ";";

View File

@ -0,0 +1,41 @@
// This is a more modern way to handle disconnecting listeners on demand.
// example usage:
/*
export class MyElement extends LitElement {
this.listenerController = new ListenerController();
connectedCallback() {
super.connectedCallback();
window.addEventListener("event-1", handler1, { signal: this.listenerController.signal });
window.addEventListener("event-2", handler2, { signal: this.listenerController.signal });
window.addEventListener("event-3", handler3, { signal: this.listenerController.signal });
}
disconnectedCallback() {
// This will disconnect *all* the event listeners at once, and resets the listenerController,
// releasing the memory used for the signal as well. No more trying to map all the
// `addEventListener` to `removeEventListener` tediousness!
this.listenerController.abort();
super.disconnectedCallback();
}
}
*/
export class ListenerController {
listenerController?: AbortController;
get signal() {
if (!this.listenerController) {
this.listenerController = new AbortController();
}
return this.listenerController.signal;
}
abort() {
this.listenerController?.abort();
this.listenerController = undefined;
}
}

View File

@ -1,14 +1,18 @@
///<reference types="@hcaptcha/types"/> ///<reference types="@hcaptcha/types"/>
import { renderStatic } from "@goauthentik/common/purify"; import { renderStatic } from "@goauthentik/common/purify";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import { akEmptyState } from "@goauthentik/elements/EmptyState";
import { bound } from "@goauthentik/elements/decorators/bound";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
import { randomId } from "@goauthentik/elements/utils/randomId"; import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic"; import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base"; import { BaseStage } from "@goauthentik/flow/stages/base";
import { P, match } from "ts-pattern";
import type { TurnstileObject } from "turnstile-types"; import type { TurnstileObject } from "turnstile-types";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
@ -23,8 +27,72 @@ import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/
interface TurnstileWindow extends Window { interface TurnstileWindow extends Window {
turnstile: TurnstileObject; turnstile: TurnstileObject;
} }
type TokenHandler = (token: string) => void; type TokenHandler = (token: string) => void;
type Dims = { height: number };
type IframeCaptchaMessage = {
source?: string;
context?: string;
message: "captcha";
token: string;
};
type IframeResizeMessage = {
source?: string;
context?: string;
message: "resize";
size: Dims;
};
type IframeMessageEvent = MessageEvent<IframeCaptchaMessage | IframeResizeMessage>;
type CaptchaHandler = {
name: string;
interactive: () => Promise<unknown>;
execute: () => Promise<unknown>;
};
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
// rendering.
const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) =>
html`<!doctype html>
<head>
<html>
<body style="display:flex;flex-direction:row;justify-content:center;">
${captchaElement}
<script>
new ResizeObserver((entries) => {
const height =
document.body.offsetHeight +
parseFloat(getComputedStyle(document.body).fontSize) * 2;
window.parent.postMessage({
message: "resize",
source: "goauthentik.io",
context: "flow-executor",
size: { height },
});
}).observe(document.querySelector(".ak-captcha-container"));
</script>
<script src=${challengeUrl}></script>
<script>
function callback(token) {
window.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token: token,
});
}
</script>
</body>
</html>
</head>`;
@customElement("ak-stage-captcha") @customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> { export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
@ -37,26 +105,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
css` css`
iframe { iframe {
width: 100%; width: 100%;
height: 73px; /* tmp */ height: 0;
} }
`, `,
]; ];
} }
handlers = [this.handleGReCaptcha, this.handleHCaptcha, this.handleTurnstile];
@state()
error?: string;
@state()
captchaFrame: HTMLIFrameElement;
@state()
captchaDocumentContainer: HTMLDivElement;
@state()
scriptElement?: HTMLScriptElement;
@property({ type: Boolean }) @property({ type: Boolean })
embedded = false; embedded = false;
@ -65,209 +119,177 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
this.host.submit({ component: "ak-stage-captcha", token }); this.host.submit({ component: "ak-stage-captcha", token });
}; };
constructor() { @state()
super(); error?: string;
this.captchaFrame = document.createElement("iframe");
this.captchaFrame.src = "about:blank";
this.captchaFrame.id = `ak-captcha-${randomId()}`;
this.captchaDocumentContainer = document.createElement("div"); handlers: CaptchaHandler[] = [
this.captchaDocumentContainer.id = `ak-captcha-${randomId()}`; {
this.messageCallback = this.messageCallback.bind(this); name: "grecaptcha",
} interactive: this.renderGReCaptchaFrame,
execute: this.executeGReCaptcha,
},
{
name: "hcaptcha",
interactive: this.renderHCaptchaFrame,
execute: this.executeHCaptcha,
},
{
name: "turnstile",
interactive: this.renderTurnstileFrame,
execute: this.executeTurnstile,
},
];
_captchaFrame?: HTMLIFrameElement;
_captchaDocumentContainer?: HTMLDivElement;
_listenController = new ListenerController();
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
window.addEventListener("message", this.messageCallback); window.addEventListener("message", this.onIframeMessage, {
signal: this._listenController.signal,
});
} }
disconnectedCallback(): void { disconnectedCallback(): void {
super.disconnectedCallback(); this._listenController.abort();
window.removeEventListener("message", this.messageCallback); if (!this.challenge?.interactive) {
if (!this.challenge.interactive) { if (document.body.contains(this.captchaDocumentContainer)) {
document.body.removeChild(this.captchaDocumentContainer); document.body.removeChild(this.captchaDocumentContainer);
} }
} }
super.disconnectedCallback();
messageCallback(
ev: MessageEvent<{
source?: string;
context?: string;
message: string;
token: string;
}>,
) {
const msg = ev.data;
if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") {
return;
}
if (msg.message !== "captcha") {
return;
}
this.onTokenChange(msg.token);
} }
async renderFrame(captchaElement: TemplateResult) { get captchaDocumentContainer() {
this.captchaFrame.contentWindow?.document.open(); if (this._captchaDocumentContainer) {
this.captchaFrame.contentWindow?.document.write( return this._captchaDocumentContainer;
await renderStatic(
html`<!doctype html>
<html>
<body style="display:flex;flex-direction:row;justify-content:center;">
${captchaElement}
<script src=${this.challenge.jsUrl}></script>
<script>
function callback(token) {
window.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token: token,
});
} }
</script> this._captchaDocumentContainer = document.createElement("div");
</body> this._captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
</html>`, return this._captchaDocumentContainer;
),
);
this.captchaFrame.contentWindow?.document.close();
} }
updated(changedProperties: PropertyValues<this>) { get captchaFrame() {
if (changedProperties.has("challenge") && this.challenge !== undefined) { if (this._captchaFrame) {
this.scriptElement = document.createElement("script"); return this._captchaFrame;
this.scriptElement.src = this.challenge.jsUrl;
this.scriptElement.async = true;
this.scriptElement.defer = true;
this.scriptElement.dataset.akCaptchaScript = "true";
this.scriptElement.onload = async () => {
console.debug("authentik/stages/captcha: script loaded");
let found = false;
let lastError = undefined;
this.handlers.forEach(async (handler) => {
let handlerFound = false;
try {
console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`);
handlerFound = await handler.apply(this);
if (handlerFound) {
console.debug(
`authentik/stages/captcha[${handler.name}]: handler succeeded`,
);
found = true;
}
} catch (exc) {
console.debug(
`authentik/stages/captcha[${handler.name}]: handler failed: ${exc}`,
);
if (handlerFound) {
lastError = exc;
}
}
});
if (!found && lastError) {
this.error = (lastError as Error).toString();
}
};
document.head
.querySelectorAll("[data-ak-captcha-script=true]")
.forEach((el) => el.remove());
document.head.appendChild(this.scriptElement);
if (!this.challenge.interactive) {
document.body.appendChild(this.captchaDocumentContainer);
}
} }
this._captchaFrame = document.createElement("iframe");
this._captchaFrame.src = "about:blank";
this._captchaFrame.id = `ak-captcha-${randomId()}`;
return this._captchaFrame;
} }
async handleGReCaptcha(): Promise<boolean> { onFrameResize({ height }: Dims) {
if (!Object.hasOwn(window, "grecaptcha")) { this.captchaFrame.style.height = `${height}px`;
return false;
} }
if (this.challenge.interactive) {
// ADR: Did not to put anything into `otherwise` or `exhaustive` here because iframe messages
// that were not of interest to us also weren't necessarily corrupt or suspicious. For example,
// during testing Storybook throws a lot of cross-iframe messages that we don't care about.
@bound
onIframeMessage({ data }: IframeMessageEvent) {
match(data)
.with(
{ source: "goauthentik.io", context: "flow-executor", message: "captcha" },
({ token }) => this.onTokenChange(token),
)
.with(
{ source: "goauthentik.io", context: "flow-executor", message: "resize" },
({ size }) => this.onFrameResize(size),
)
.with(
{ source: "goauthentik.io", context: "flow-executor", message: P.any },
({ message }) => {
console.debug(`authentik/stages/captcha: Unknown message: ${message}`);
},
)
.otherwise(() => {});
}
async renderGReCaptchaFrame() {
this.renderFrame( this.renderFrame(
html`<div html`<div
class="g-recaptcha" class="g-recaptcha ak-captcha-container"
data-sitekey="${this.challenge.siteKey}" data-sitekey="${this.challenge.siteKey}"
data-callback="callback" data-callback="callback"
></div>`, ></div>`,
); );
} else { }
grecaptcha.ready(() => {
const captchaId = grecaptcha.render(this.captchaDocumentContainer, { async executeGReCaptcha() {
return grecaptcha.ready(() => {
grecaptcha.execute(
grecaptcha.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey, sitekey: this.challenge.siteKey,
callback: this.onTokenChange, callback: this.onTokenChange,
size: "invisible", size: "invisible",
}),
);
}); });
grecaptcha.execute(captchaId);
});
}
return true;
} }
async handleHCaptcha(): Promise<boolean> { async renderHCaptchaFrame() {
if (!Object.hasOwn(window, "hcaptcha")) {
return false;
}
if (this.challenge.interactive) {
this.renderFrame( this.renderFrame(
html`<div html`<div
class="h-captcha" class="h-captcha ak-captcha-container"
data-sitekey="${this.challenge.siteKey}" data-sitekey="${this.challenge.siteKey}"
data-theme="${this.activeTheme ? this.activeTheme : "light"}" data-theme="${this.activeTheme ? this.activeTheme : "light"}"
data-callback="callback" data-callback="callback"
></div> `, ></div> `,
); );
} else { }
const captchaId = hcaptcha.render(this.captchaDocumentContainer, {
async executeHCaptcha() {
return hcaptcha.execute(
hcaptcha.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey, sitekey: this.challenge.siteKey,
callback: this.onTokenChange, callback: this.onTokenChange,
size: "invisible", size: "invisible",
}); }),
hcaptcha.execute(captchaId); );
}
return true;
} }
async handleTurnstile(): Promise<boolean> { async renderTurnstileFrame() {
if (!Object.hasOwn(window, "turnstile")) {
return false;
}
if (this.challenge.interactive) {
this.renderFrame( this.renderFrame(
html`<div html`<div
class="cf-turnstile" class="cf-turnstile ak-captcha-container"
data-sitekey="${this.challenge.siteKey}" data-sitekey="${this.challenge.siteKey}"
data-callback="callback" data-callback="callback"
></div>`, ></div>`,
); );
} else { }
(window as unknown as TurnstileWindow).turnstile.render(this.captchaDocumentContainer, {
async executeTurnstile() {
return (window as unknown as TurnstileWindow).turnstile.render(
this.captchaDocumentContainer,
{
sitekey: this.challenge.siteKey, sitekey: this.challenge.siteKey,
callback: this.onTokenChange, callback: this.onTokenChange,
}); },
);
} }
return true;
async renderFrame(captchaElement: TemplateResult) {
this.captchaFrame.contentWindow?.document.open();
this.captchaFrame.contentWindow?.document.write(
await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
);
this.captchaFrame.contentWindow?.document.close();
} }
renderBody() { renderBody() {
if (this.error) { // [hasError, isInteractive]
return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`; // prettier-ignore
} return match([Boolean(this.error), Boolean(this.challenge?.interactive)])
if (this.challenge.interactive) { .with([true, P.any], () => akEmptyState({ icon: "fa-times", header: this.error }))
return html`${this.captchaFrame}`; .with([false, true], () => html`${this.captchaFrame}`)
} .with([false, false], () => akEmptyState({ loading: true, header: msg("Verifying...") }))
return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`; .exhaustive();
} }
render() { renderMain() {
if (this.embedded) {
if (!this.challenge.interactive) {
return html``;
}
return this.renderBody();
}
if (!this.challenge) {
return html`<ak-empty-state loading> </ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header"> return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1> <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header> </header>
@ -291,6 +313,63 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
<ul class="pf-c-login__main-footer-links"></ul> <ul class="pf-c-login__main-footer-links"></ul>
</footer>`; </footer>`;
} }
render() {
// [isEmbedded, hasChallenge, isInteractive]
// prettier-ignore
return match([this.embedded, Boolean(this.challenge), Boolean(this.challenge?.interactive)])
.with([true, false, P.any], () => nothing)
.with([true, true, false], () => nothing)
.with([true, true, true], () => this.renderBody())
.with([false, false, P.any], () => akEmptyState({ loading: true }))
.with([false, true, P.any], () => this.renderMain())
.exhaustive();
}
updated(changedProperties: PropertyValues<this>) {
if (!(changedProperties.has("challenge") && this.challenge !== undefined)) {
return;
}
const attachCaptcha = async () => {
console.debug("authentik/stages/captcha: script loaded");
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
let lastError = undefined;
let found = false;
for (const { name, interactive, execute } of handlers) {
console.debug(`authentik/stages/captcha: trying handler ${name}`);
try {
const runner = this.challenge.interactive ? interactive : execute;
await runner.apply(this);
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
found = true;
break;
} catch (exc) {
console.debug(`authentik/stages/captcha[${name}]: handler failed`);
console.debug(exc);
lastError = exc;
}
}
this.error = found ? undefined : (lastError ?? "Unspecified error").toString();
};
const scriptElement = document.createElement("script");
scriptElement.src = this.challenge.jsUrl;
scriptElement.async = true;
scriptElement.defer = true;
scriptElement.dataset.akCaptchaScript = "true";
scriptElement.onload = attachCaptcha;
document.head
.querySelectorAll("[data-ak-captcha-script=true]")
.forEach((el) => el.remove());
document.head.appendChild(scriptElement);
if (!this.challenge.interactive) {
document.body.appendChild(this.captchaDocumentContainer);
}
}
} }
declare global { declare global {

View File

@ -101,7 +101,15 @@ The following events occur when a license expires or the internal/external user
- After another 2 weeks, users get a warning banner - After another 2 weeks, users get a warning banner
- After another 2 weeks, the authentik Enterprise instance becomes read-only - After another 2 weeks, the authentik Enterprise instance becomes "read-only"
When an authentik instance is in read-only mode, the following actions are still possible:
- Users can authenticate and authorize applications
- Licenses can be modified
- Users can be modified/deleted <span class="badge badge--version">authentik 2024.10.5+</span>
After the violation is corrected (either the user count returns to be within the limits of the license or the license is renewed), authentik will return to the standard read-write mode and the notification will disappear.
### About users and licenses ### About users and licenses