Skip to content

enterprise: account lockdown#18615

Open
dominic-r wants to merge 113 commits intomainfrom
sdko/panic-button
Open

enterprise: account lockdown#18615
dominic-r wants to merge 113 commits intomainfrom
sdko/panic-button

Conversation

@dominic-r
Copy link
Member

@dominic-r dominic-r commented Dec 4, 2025

Overview:

This PR introduces Account Lockdown (enterprise), allows admins to secure user accounts in emergency situations.

Key capabilities:

  • Deactivates the user account
  • Sets the user's password to a random value
  • Terminates all active sessions across devices
  • Revokes all tokens (API, OAuth access, refresh tokens)
  • Supports bulk lockdown for multiple users (Gergo is gonna love this one)
  • Self-service lockdown allowing users to lock their own account

Screenshots:

$TODO

Testing:

Unit tests and manual testing

Motivation:

Internal, Closes: #18160

@dominic-r dominic-r added this to the Release 2026.2 milestone Dec 4, 2025
@dominic-r dominic-r self-assigned this Dec 4, 2025
@dominic-r dominic-r added the area:frontend Features or issues related to the browser, TypeScript, Node.js, etc label Dec 4, 2025
@dominic-r dominic-r linked an issue Dec 4, 2025 that may be closed by this pull request
@netlify
Copy link

netlify bot commented Dec 4, 2025

Deploy Preview for authentik-integrations ready!

Name Link
🔨 Latest commit b89bac9
🔍 Latest deploy log https://app.netlify.com/projects/authentik-integrations/deploys/69b4df5b1df385000822a0e6
😎 Deploy Preview https://deploy-preview-18615--authentik-integrations.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Dec 4, 2025

Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit b89bac9
🔍 Latest deploy log https://app.netlify.com/projects/authentik-docs/deploys/69b4df5b0c84120007006f40
😎 Deploy Preview https://deploy-preview-18615--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Dec 4, 2025

Deploy Preview for authentik-storybook ready!

Name Link
🔨 Latest commit b89bac9
🔍 Latest deploy log https://app.netlify.com/projects/authentik-storybook/deploys/69b4df5b1df385000822a0e2
😎 Deploy Preview https://deploy-preview-18615--authentik-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov
Copy link

codecov bot commented Dec 4, 2025

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
3144 1 3143 4
View the top 1 failed test(s) by shortest run time
authentik.enterprise.stages.account_lockdown.tests.test_stage.TestAccountLockdownStage::test_lockdown_revokes_oauth_tokens
Stack Traces | 2.32s run time
self = <unittest.case._Outcome object at 0x7f8f11316740>
test_case = <authentik.enterprise.stages.account_lockdown.tests.test_stage.TestAccountLockdownStage testMethod=test_lockdown_revokes_oauth_tokens>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.3........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.account_lockdown.tests.test_stage.TestAccountLockdownStage testMethod=test_lockdown_revokes_oauth_tokens>
result = <TestCaseFunction test_lockdown_revokes_oauth_tokens>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.3........./x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.account_lockdown.tests.test_stage.TestAccountLockdownStage testMethod=test_lockdown_revokes_oauth_tokens>
method = <bound method TestAccountLockdownStage.test_lockdown_revokes_oauth_tokens of <authentik.enterprise.stages.account_lockdown.tests.test_stage.TestAccountLockdownStage testMethod=test_lockdown_revokes_oauth_tokens>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.3........./x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.account_lockdown.tests.test_stage.TestAccountLockdownStage testMethod=test_lockdown_revokes_oauth_tokens>

    def test_lockdown_revokes_oauth_tokens(self):
        """Test lockdown stage revokes OAuth2 grants."""
        provider = OAuth2Provider.objects.create(
            name=generate_id(),
            authorization_flow=create_test_flow(),
            redirect_uris=[
                RedirectURI(RedirectURIMatchingMode.STRICT, "http:.../testserver/callback")
            ],
            signing_key=create_test_cert(),
        )
        session = Session.objects.create(
            session_key=generate_id(),
            expires=timezone.now() + timezone.timedelta(hours=1),
            last_ip="127.0.0.1",
        )
        auth_session = AuthenticatedSession.objects.create(
            session=session,
            user=self.target_user,
        )
        token_kwargs = {
            "provider": provider,
            "user": self.target_user,
            "auth_time": timezone.now(),
            "_scope": "openid profile",
            "_id_token": json.dumps(asdict(IDToken("foo", "bar"))),
        }
>       AuthorizationCode.objects.create(
            code=generate_id(),
            session=auth_session,
            **token_kwargs,
        )

.../account_lockdown/tests/test_stage.py:265: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <django.db.models.manager.Manager object at 0x7f8f154b68d0>, args = ()
kwargs = {'_id_token': '{"iss": "foo", "sub": "bar", "aud": null, "exp": null, "iat": null, "auth_time": null, "acr": "goauthen...2026, 3, 14, 4, 25, 28, 284729, tzinfo=datetime.timezone.utc), 'code': 'KS8SB9vpfqSJjamilXxn0UBOxasq6U9YfYOnhd9G', ...}

    @wraps(method)
    def manager_method(self, *args, **kwargs):
>       return getattr(self.get_queryset(), name)(*args, **kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.venv/lib/python3.14.../db/models/manager.py:87: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <QuerySet []>
kwargs = {'_id_token': '{"iss": "foo", "sub": "bar", "aud": null, "exp": null, "iat": null, "auth_time": null, "acr": "goauthen...2026, 3, 14, 4, 25, 28, 284729, tzinfo=datetime.timezone.utc), 'code': 'KS8SB9vpfqSJjamilXxn0UBOxasq6U9YfYOnhd9G', ...}
reverse_one_to_one_fields = frozenset()

    def create(self, **kwargs):
        """
        Create a new object with the given kwargs, saving it to the database
        and returning the created object.
        """
        reverse_one_to_one_fields = frozenset(kwargs).intersection(
            self.model._meta._reverse_one_to_one_field_names
        )
        if reverse_one_to_one_fields:
            raise ValueError(
                "The following fields do not exist in this model: %s"
                % ", ".join(reverse_one_to_one_fields)
            )
    
>       obj = self.model(**kwargs)
              ^^^^^^^^^^^^^^^^^^^^

.venv/lib/python3.14.../db/models/query.py:663: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <AuthorizationCode: Authorization code for 70 for user 253>, args = ()
kwargs = {'_id_token': '{"iss": "foo", "sub": "bar", "aud": null, "exp": null, "iat": null, "auth_time": null, "acr": "goauthen...oauth2/default", "amr": null, "c_hash": null, "nonce": null, "at_hash": null, "sid": null, "jti": null, "claims": {}}'}
cls = <class 'authentik.providers.oauth2.models.AuthorizationCode'>
opts = <Options for AuthorizationCode>, _setattr = <built-in function setattr>
_DEFERRED = <Deferred field>
fields_iter = <tuple_iterator object at 0x7f8f10f2bfa0>, val = None
field = <django.db.models.fields.CharField: code_challenge_method>
is_related_object = False
rel_obj = <AuthenticatedSession: Authenticated Session k5GBdA0tZ3>
property_names = frozenset({'c_hash', 'is_expired', 'pk', 'scope', 'serializer'})

    def __init__(self, *args, **kwargs):
        # Alias some things as locals to avoid repeat global lookups
        cls = self.__class__
        opts = self._meta
        _setattr = setattr
        _DEFERRED = DEFERRED
        if opts.abstract:
            raise TypeError("Abstract models cannot be instantiated.")
    
        pre_init.send(sender=cls, args=args, kwargs=kwargs)
    
        # Set up the storage for instance state
        self._state = ModelState()
    
        # There is a rather weird disparity here; if kwargs, it's set, then args
        # overrides it. It should be one or the other; don't duplicate the work
        # The reason for the kwargs check is that standard iterator passes in by
        # args, and instantiation for iteration is 33% faster.
        if len(args) > len(opts.concrete_fields):
            # Daft, but matches old exception sans the err msg.
            raise IndexError("Number of args exceeds number of fields")
    
        if not kwargs:
            fields_iter = iter(opts.concrete_fields)
            # The ordering of the zip calls matter - zip throws StopIteration
            # when an iter throws it. So if the first iter throws it, the second
            # is *not* consumed. We rely on this, so don't change the order
            # without changing the logic.
            for val, field in zip(args, fields_iter):
                if val is _DEFERRED:
                    continue
                _setattr(self, field.attname, val)
        else:
            # Slower, kwargs-ready version.
            fields_iter = iter(opts.fields)
            for val, field in zip(args, fields_iter):
                if val is _DEFERRED:
                    continue
                _setattr(self, field.attname, val)
                if kwargs.pop(field.name, NOT_PROVIDED) is not NOT_PROVIDED:
                    raise TypeError(
                        f"{cls.__qualname__}() got both positional and "
                        f"keyword arguments for field '{field.name}'."
                    )
    
        # Now we're left with the unprocessed fields that *must* come from
        # keywords, or default.
    
        for field in fields_iter:
            is_related_object = False
            # Virtual field
            if field.column is None or field.generated:
                continue
            if kwargs:
                if isinstance(field.remote_field, ForeignObjectRel):
                    try:
                        # Assume object instance was passed in.
                        rel_obj = kwargs.pop(field.name)
                        is_related_object = True
                    except KeyError:
                        try:
                            # Object instance wasn't passed in -- must be an ID.
                            val = kwargs.pop(field.attname)
                        except KeyError:
                            val = field.get_default()
                else:
                    try:
                        val = kwargs.pop(field.attname)
                    except KeyError:
                        # This is done with an exception rather than the
                        # default argument on pop because we don't want
                        # get_default() to be evaluated, and then not used.
                        # Refs #12057.
                        val = field.get_default()
            else:
                val = field.get_default()
    
            if is_related_object:
                # If we are passed a related instance, set it using the
                # field.name instead of field.attname (e.g. "user" instead of
                # "user_id") so that the object gets properly cached (and type
                # checked) by the RelatedObjectDescriptor.
                if rel_obj is not _DEFERRED:
                    _setattr(self, field.name, rel_obj)
            else:
                if val is not _DEFERRED:
                    _setattr(self, field.attname, val)
    
        if kwargs:
            property_names = opts._property_names
            unexpected = ()
            for prop, value in kwargs.items():
                # Any remaining kwargs must correspond to properties or virtual
                # fields.
                if prop in property_names:
                    if value is not _DEFERRED:
                        _setattr(self, prop, value)
                else:
                    try:
                        opts.get_field(prop)
                    except FieldDoesNotExist:
                        unexpected += (prop,)
                    else:
                        if value is not _DEFERRED:
                            _setattr(self, prop, value)
            if unexpected:
                unexpected_names = ", ".join(repr(n) for n in unexpected)
>               raise TypeError(
                    f"{cls.__name__}() got unexpected keyword arguments: "
                    f"{unexpected_names}"
                )
E               TypeError: AuthorizationCode() got unexpected keyword arguments: '_id_token'

.venv/lib/python3.14.../db/models/base.py:569: TypeError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@dominic-r dominic-r marked this pull request as ready for review December 22, 2025 16:01
@dominic-r dominic-r requested review from a team as code owners December 22, 2025 16:01
@github-actions
Copy link
Contributor

github-actions bot commented Dec 23, 2025

authentik PR Installation instructions

Instructions for docker-compose

Add the following block to your .env file:

AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
AUTHENTIK_TAG=gh-219f2e3ba97b26fca4f01ad8813a28adb52674a8
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s

Afterwards, run the upgrade commands from the latest release notes.

Instructions for Kubernetes

Add the following block to your values.yml file:

authentik:
    outposts:
        container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
global:
    image:
        repository: ghcr.io/goauthentik/dev-server
        tag: gh-219f2e3ba97b26fca4f01ad8813a28adb52674a8

Afterwards, run the upgrade commands from the latest release notes.

@dominic-r dominic-r requested a review from a team as a code owner December 27, 2025 00:28
Copy link
Contributor

@dewi-tik dewi-tik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few minor changes to docs.

@github-project-automation github-project-automation bot moved this from Todo to In Progress in authentik Core Dec 29, 2025
@dominic-r dominic-r marked this pull request as draft December 31, 2025 00:36
@dominic-r dominic-r marked this pull request as ready for review December 31, 2025 21:01
@dominic-r dominic-r changed the title core: panic button enterprise: account lockdown Jan 2, 2026
@dominic-r dominic-r force-pushed the sdko/panic-button branch 2 times, most recently from 2f37a81 to 45d33ba Compare January 26, 2026 01:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend area:frontend Features or issues related to the browser, TypeScript, Node.js, etc

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

"Panic button" in admin interface

5 participants