goauthentik / goauthentik/authentik

User expiration proposal

Open
#26,172 1 comment 0 reactions 2 assignees Claimed by @BeryJu View on GitHub
enhancement enhancement/confirmed
Dominant language
Python
Stars
25.6k
Forks
2k
Avg merge
1d 2h
Merged PRs (30d)
651

Description

# User expiration

This is the second half of #22823. Offboarding (#23624) gave us scheduled deactivate/delete per user. This adds the "automatically, when someone has been inactive for N days" part. Enterprise only, same as offboarding.

## The idea

We add one model, `UserExpirationRule`: a name, a group (or all users), an inactivity duration, deactivate or delete, and optionally a warning window plus which transports to send the warning through.

An hourly task looks at each enabled rule and finds users who've crossed the line. Instead of deactivating them on the spot, it creates a normal `UserOffboarding` row for them, due at last activity + duration. From there we don't write anything new: the existing 5 minute sweeper picks it up, retries work, the row shows in Events > Offboardings and on the user page, and the Cancel button already exists.

If the user logs in before the due date, we delete the row. If they don't, the offboarding runs, and right before it does we re-check that the rule still applies to them.

## Why go through offboarding rows

Mostly because we get a lot for free: visibility of what's about to happen, a way for admins to cancel, locking and retry, and the "one pending offboarding per user" constraint that already exists. The only coupling we add is a nullable `rule` FK on `UserOffboarding` and a one-query cleanup on login.

The warning also falls out naturally. If a rule has `warn_before` set to 7 days, the row gets created 7 days early and we notify the user then. No separate "warned at" state.

## What counts as inactive

`GREATEST(last_login, date_joined)` plus the duration. Users who never logged in count from creation, so accounts provisioned by a source and never used don't sit around forever.

Things that update `last_login` today: flow logins, LDAP/RADIUS binds, proxy, OAuth client credentials. API token usage does not. That's why service accounts are excluded by default, and we need a sentence in the docs saying so.

## Who gets skipped

- Internal service accounts, always.
- Regular service accounts by default (configurable via `user_types`).
- Superusers by default (`exclude_superusers`), so nobody locks every admin out with an all-users rule and a short duration.
- Anyone with a pending offboarding already, manual or automatic.
- Anyone whose last automatic offboarding (completed, canceled or failed) is newer than their last login. This one does a lot of work: a canceled row exempts the user until they log in again, a reactivated user doesn't get re-expired the next hour, and a failed row doesn't retry in a loop.
- Users who fail the rule's policy bindings, if any are set.

Two things to write down in the docs: a canceled user who never logs in again stays exempt forever, and a reactivated user who never logs in again stays active forever. Both are explicit admin decisions and manual offboarding still works.

## When rules overlap or change

If two rules match the same user, the tighter one wins. A pending row from a looser rule gets updated in place (rule, date, action) and the user gets a fresh warning, because the old one is now wrong on both date and consequence.

If a rule gets looser (longer duration, user moved to a different group), the sweep pushes the pending row out silently, or deletes it if the user no longer qualifies. No notification, since "later than promised" breaks nothing.

Rule tasks run in parallel, so there's some locking: a rule only loosens rows it owns, and tightening takes the row under `select_for_update` and writes only if the new date is still earlier.

## Performance

Cost per run scales with newly due users, not total users. The threshold is written so the `last_login` index is used (no `GREATEST()` in the WHERE clause), users with a pending row drop out of every later sweep, and the terminal-row exclusion gets a partial index. Policies only run on the already-narrowed candidates. Each rule is its own task.

The one expensive moment is the first run after enabling a rule on an instance with lots of dormant users. That's inherent and happens once.

## Enterprise gating

Same patterns as offboarding: `EnterpriseRequiredMixin` on the serializer, the sweeper returns early without a license, generated rows stay pending (don't execute) without a license, sidebar entry flagged enterprise, docs frontmatter. Manual offboarding is untouched.

## What's new for admins

- Events > Expiration rules: list and form.
- A preview endpoint on the rule so you can see "this would schedule 4,812 users" before flipping it on.
- Events > Offboardings gets a "Scheduled by" column (admin or rule name) and a manual/automatic filter.
- User page shows "Expires on (rule )" with the existing Cancel button.
- New event `USER_EXPIRATION_WARNING`. `USER_OFFBOARDED` gets `rule` in its context. Admins who want to hear about it set up a notification rule like for any other event.

Out of scope: telling the user "your account was locked for inactivity" at login. That needs the `is_active` to `status` refactor from the issue.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.