You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Nothing in the UI tells an administrator whether an account holds a local password. The admin user
detail page shows the authentication source and little else
(templates/admin/user/view_details.tmpl:17-19, rendering Local when the source ID is 0), and the
user list has no such column or filter (templates/admin/user/list.tmpl:62-76).
That would be tolerable if "auth source = Local" and "has a local password" meant the same thing, but
they do not:
LDAP accounts have no local password (services/auth/source/ldap/source_authenticate.go:82-90).
SMTP and PAM accounts do have one — a copy of the remote password captured at auto-registration
(services/auth/source/smtp/source_authenticate.go:73, services/auth/source/pam/source_authenticate.go:57) — which is never used for verification.
OAuth2 accounts may or may not have one, and when they do it is the password they actually sign in
with, because the OAuth2 source delegates verification to the DB authenticator
(services/auth/source/oauth2/source_authenticate.go:15). The link-register form deliberately leaves
it empty for external-only registration (routers/web/auth/linkaccount.go:213-219).
Local accounts can have an empty password too, in which case they cannot sign in with one at all.
So today the question "does this account have a local password, and who verifies it?" can only be
answered by querying the database directly. IsLocal() (models/user/user.go:238) does not answer it,
and neither does the source name shown in the UI.
Why this should land before the purge
#38905 proposes clearing the copied remote password hashes for
LDAP/SMTP/PAM accounts. That migration should not be the first time an administrator can see the state
it is about to change:
Before upgrading, an administrator should be able to enumerate exactly which accounts hold a local
password hash, and cross-check it against the migration's report.
After the migration, IsPasswordSet() flips to false for those accounts. Without a visible
explanation of what a local password is and who verifies this account's password, that looks like
data loss rather than an intended cleanup.
Precise predicates in models/user, replacing inference from IsLocal():
HasLocalPassword() — a local password hash exists.
PasswordVerifiedLocally() — a submitted password is checked against that hash, which is true for
local and OAuth2 accounts today.
These make the distinction expressible in templates and reusable by the doctor check.
Admin user detail (templates/admin/user/view_details.tmpl): two rows next to the existing
auth-source row —
Local password: ✓ / ✗
Password verified by: Local / <source name>
For an OAuth2 account the second row reads Local, which is what actually happens and is currently
invisible.
Admin user list filter: add HasLocalPassword optional.Option[bool] alongside the existing
options (models/user/search.go:53-57) and a status_filter[has_local_password] entry
(routers/web/admin/users.go:54 and :75), following the is_2fa_enabled precedent
(models/user/search.go:143-151). This is what lets an administrator enumerate affected accounts
from the UI rather than with SQL.
Own account page: replace the generic settings.password_change_disabled message
(templates/user/settings/account.tmpl:31) with one that names the source managing the password, so
a user who cannot change their password learns why. New strings go in options/locale/locale_en-US.json only.
Deliberately not part of this: exposing password state through the public user API
(modules/structs/user.go). This is administrative information and stays on admin-only surfaces plus
the doctor output.
Tests
Unit test for the new search filter over fixtures covering local-with-password,
local-without-password, LDAP, SMTP-with-captured-hash and OAuth2 accounts.
Integration test asserting the admin detail page renders both states correctly, including the OAuth2
case where the source is external but verification is local.
Assertion that the new predicates and the doctor counts agree on the same fixture set.
Context
Prerequisite for #38905, and Step 1a of #38904. It stands alone: no schema change, no behaviour change,
only visibility.
Problem
Nothing in the UI tells an administrator whether an account holds a local password. The admin user
detail page shows the authentication source and little else
(
templates/admin/user/view_details.tmpl:17-19, renderingLocalwhen the source ID is 0), and theuser list has no such column or filter (
templates/admin/user/list.tmpl:62-76).That would be tolerable if "auth source = Local" and "has a local password" meant the same thing, but
they do not:
services/auth/source/ldap/source_authenticate.go:82-90).(
services/auth/source/smtp/source_authenticate.go:73,services/auth/source/pam/source_authenticate.go:57) — which is never used for verification.with, because the OAuth2 source delegates verification to the DB authenticator
(
services/auth/source/oauth2/source_authenticate.go:15). The link-register form deliberately leavesit empty for external-only registration (
routers/web/auth/linkaccount.go:213-219).So today the question "does this account have a local password, and who verifies it?" can only be
answered by querying the database directly.
IsLocal()(models/user/user.go:238) does not answer it,and neither does the source name shown in the UI.
Why this should land before the purge
#38905 proposes clearing the copied remote password hashes for
LDAP/SMTP/PAM accounts. That migration should not be the first time an administrator can see the state
it is about to change:
password hash, and cross-check it against the migration's report.
IsPasswordSet()flips tofalsefor those accounts. Without a visibleexplanation of what a local password is and who verifies this account's password, that looks like
data loss rather than an intended cleanup.
account to the local source no longer silently re-enables the old remote password — understandable
instead of surprising.
Proposal
models/user, replacing inference fromIsLocal():HasLocalPassword()— a local password hash exists.PasswordVerifiedLocally()— a submitted password is checked against that hash, which is true forlocal and OAuth2 accounts today.
These make the distinction expressible in templates and reusable by the doctor check.
templates/admin/user/view_details.tmpl): two rows next to the existingauth-source row —
Local password: ✓ / ✗Password verified by: Local / <source name>For an OAuth2 account the second row reads
Local, which is what actually happens and is currentlyinvisible.
HasLocalPassword optional.Option[bool]alongside the existingoptions (
models/user/search.go:53-57) and astatus_filter[has_local_password]entry(
routers/web/admin/users.go:54and:75), following theis_2fa_enabledprecedent(
models/user/search.go:143-151). This is what lets an administrator enumerate affected accountsfrom the UI rather than with SQL.
settings.password_change_disabledmessage(
templates/user/settings/account.tmpl:31) with one that names the source managing the password, soa user who cannot change their password learns why. New strings go in
options/locale/locale_en-US.jsononly.local accounts with no password — shared with Step 1 of
Proposal: refactor the user and authentication system #38904 so the CLI and the UI agree.
Deliberately not part of this: exposing password state through the public user API
(
modules/structs/user.go). This is administrative information and stays on admin-only surfaces plusthe doctor output.
Tests
local-without-password, LDAP, SMTP-with-captured-hash and OAuth2 accounts.
case where the source is external but verification is local.
Context
Prerequisite for #38905, and Step 1a of
#38904. It stands alone: no schema change, no behaviour change,
only visibility.
Assisted-by: Codet:claude-opus-4-6