docs(authentik): password change is a prompt field, not a PasswordStage

The UserWriteStage writes prompt_data['password'] via user.set_password().
A PasswordStage only authenticates the current password. Document the
correct mechanism (password field on the default-user-settings prompt +
empty-password guard policy) and the throwaway-user verification.
This commit is contained in:
Frank
2026-09-24 21:28:14 -07:00
parent 8108dc4254
commit 650dcba0ea
@@ -285,20 +285,33 @@ insertion order):
| order | stage | type | what it does |
|---|---|---|---|
| 20 | `default-user-settings` | `PromptStage` | intro prompt |
| 100 | `default-user-settings-write` | `UserWriteStage` | edit name / email / locale |
| 101 | `default-user-settings-password` | `PasswordStage` | change password (`InbuiltBackend`) |
| 20 | `default-user-settings` | `PromptStage` | name / email / locale **+ password** |
| 100 | `default-user-settings-write` | `UserWriteStage` | writes the prompt data to the user |
**The `PasswordStage` was missing until 2026-09-25.** The stock flow only
carried the prompt + `UserWriteStage`, so users could edit name/email/locale
but had no way to change their password. The fix: create a `PasswordStage`
(backend `authentik.core.auth.InbuiltBackend`) and bind it to the flow. Two
ORM gotchas: `Flow.stages.add()` fails on this through model
**Password change is a prompt field, not a stage.** The `UserWriteStage`
writes whatever the preceding `PromptStage` collected: for the key `password`
it calls `user.set_password(value)` (`stages/user_write/stage.py`). So the fix
is a `password` field on the `default-user-settings` prompt
(`field_key=password`, `type=password`, `required=False`, order 204) — NOT a
`PasswordStage`. A `PasswordStage` is an *authentication* stage: its
`validate_password` runs `authenticate()` against the backends to check the
*current* password; it never sets a new one. (A `PasswordStage` was added on
2026-09-25 and removed the same day for exactly this reason.)
Two ORM gotchas: `Flow.stages.add()` fails on this through model
(`Can't bulk create a multi-table inherited model` — `FlowStageBinding` is
MTI), so create the `FlowStageBinding` row directly with an explicit `order`;
and `PasswordStage` requires the `backends` array (a bare `name` insert hits a
`NOT NULL` violation). Verified via the `FlowPlanner` — the plan now lists all
three stages.
and a prompt field is a `Prompt` row linked through `PromptStageField`
(`prompt_stage` + `prompt`; the order comes from `Prompt.order`).
**A blank password must not set an empty password.** `set_password("")` would
make the account log in with no password, so a validation policy
(`default-user-settings-no-empty-password`, bound to the prompt via
`PromptStageValidationPolicy`) denies the form when `prompt_data["password"]`
is `""` and allows it when set. Verified with the `PolicyEvaluator`
(empty → deny, set → allow, absent → allow) and end-to-end by driving
`UserWriteStageView.update_user` on a throwaway user (new password valid, old
invalid).
## Outpost claim cache (`proxysession`) — 403 after a group add