Uh oh!
There was an error while loading. Please reload this page.
Fix admin_update_user_password/3 writing the hash for any caller - #690
Conversation
`update_user_status/3` was moved into the context precisely because a rank rule
that lives in a LiveView does not live anywhere — the guard had been added to
the edit form while the user-list and user-detail pages reached the context
ungated. Credential management was left behind in that move: the edit form asks
`can_manage_user_credentials?/2` to hide the UI and to refuse the event, but
`admin_update_user_password/3` itself asked nothing and wrote the hash for
whoever reached it.
The actor was already in hand. `:admin_user` has always been threaded through
`context` for the audit row, so only the question was missing. It now authorizes
as well as audits, in the same shape as its sibling: an actor present and out of
rank is refused with `{:error, :insufficient_permissions}`, and an absent actor
stays the system path for seeds, migrations and mix tasks.
Not exploitable through the shipped UI today — the one live caller gates on
`socket.assigns.can_manage_credentials` in all four of its handlers — so this
closes the second caller before it exists rather than a hole in front of one.
Credentials are the more final half of the pair: setting a password also deletes
every session token of the target.
Tests assert on the stored hash rather than the return tuple, so a guard that
refused only after writing would still fail them. Verified red first: the three
refusal cases returned `{:ok, %User{}}` before the change.
timujinne
left a comment
There was a problem hiding this comment.
Combined review — PR #690
Three independent reviewers, same brief, none saw the others: Kimi K3
(ask-kimi, max effort), GLM-5.2 (ask-glm, ASK_EFFORT=max), and Opus
(project reviewer agent, the only one with a shell).
Coverage caveat, stated up front. GLM returned only its closing summary —
the body of its review was lost in transport, so its findings below are recorded
at the level of detail it gave, not as a full pass. Opus produced no deliverable
within the window. Everything marked verified below was checked by me directly
against the tree, and the CRITICAL was reproduced in a sandboxed test run.
Verdict: NEEDS-WORK. Three blocking items. The first one is not about this
PR's code — it is about the claim this PR inherits.
BLOCKING 1 — BUG - CRITICAL: the takeover this line of work claims to close is still wide open
Found by Kimi; reached from a different direction by GLM; reproduced by me.
user_form.ex:559 takes custom_fields straight from client params:
custom_fields_params=Map.get(user_params,"custom_fields",%{})and hands it to Auth.update_user_fields/2 (user_form.ex:716), inside thewith chain at user_form.ex:576 — with no authority check anywhere on that
path. update_user_fields/2 (auth.ex:1808) then resolves each key withString.to_existing_atom/1 and, if it lands in
updatable_profile_fields=[:first_name,:last_name,:email,:username,:user_timezone]writes it into the schema via profile_changeset, which casts :email and:username directly — no confirmation-token flow.
The credential defence added by #686 is Map.drop(profile_params, ["password", "email", "username"]) at user_form.ex:643-650. It guards profile_params.
It has nothing to do with custom_fields_params.
Do X: as an actor holding only the users permission — whichuser_form.ex:95-96 admits onto the page — open the Owner's edit form and submituser[custom_fields][email]=attacker@example.com.
Observe Y (sandbox run, this tree):
email before: probe_target@example.com after: attacker@example.com
username after: "attacker"
custom_fields after: %{}
custom_fields coming back empty is the proof: the keys were not stored as
custom fields, they were routed into the columns.
From there: public forgot-password page → reset mail lands in the attacker's
inbox → reset_user_password/2 (token-gated by design, correctly no rank check)
→ session as Owner. The same payload with "username" removes a sign-in route
from an account the actor may not manage.
This is pre-existing, not introduced here. It is blocking anyway, because it
is the direct answer to what this PR and #686 claim to have done: the credential
rank rule is bypassable end-to-end, and the guard shipped in #686 reads as
complete when it is not.
Fix: filter custom_fields_params against the schema-field list inupdate_user/2 before it reaches update_user_fields/2, using the samecan_manage_credentials answer the profile path already uses. The caller knows
the authority; update_user_fields/2 does not and should not have to.
BLOCKING 2 — BUG - MEDIUM: the new return shape crashes its only caller, after a partial write
Found independently by both Kimi and GLM. Verified by reading.
This one is introduced by this PR. update_profile_and_password/3
(user_form.ex:936) matches:
{:error,password_changeset}->profile_changeset=Auth.change_user_registration(user,user_params)combined_changeset=merge_password_errors(profile_changeset,password_changeset)The new {:error, :insufficient_permissions} matches that clause, binding the
atom as password_changeset; merge_password_errors/2 then readspassword_changeset.errors → BadMapError, LiveView crashes. Worse, the profile
write at user_form.ex:940 has already committed, so the refusal leaves a
partial write.
Reachability — narrower than the reviewers stated, and I want that on record.
The password branch is only entered when password_provided is true, which
requires the password param to have survived the Map.drop atuser_form.ex:643 — which requires can_manage_credentials to be true. So this
needs the mount-time assign (user_form.ex:105-112) to have gone stale: the
Owner opens U's form, U is promoted to Owner (or the actor demoted), then save.
A race, not a walk-up. It is still a regression this PR introduces, and the
partial write makes it worse than a clean error.
Fix: match {:error, :insufficient_permissions} explicitly, refuse through
the existing deny_credential_action/1, and decide authority before writing
the profile rather than after.
BLOCKING 3 — BUG - MEDIUM: the system path is fail-open on shape
Found by Kimi.
caseMap.get(context,:admin_user)do%User{}=actor->..._system->do_admin_update_user_password(user,attrs,context)endAny value that is present but not a %User{} — a map decoded from JSON by a host
application's API controller, a bare uuid string, false — silently takes the
unchecked path. Auth.admin_update_user_password(owner, %{password: "X"}, %{admin_user: %{"uuid" => "…"}}) writes the hash.
This matters more than it looks because PhoenixKit is a library: the unchecked
2-arg form is the default and the moduledoc's first example, so nothing
distinguishes "a mix task with no actor" from "a host caller that forgot".
Fix: only nil/absent is the system path; any other value is{:error, :insufficient_permissions}. toggle_user_confirmation/2
(auth.ex:1331) carries the identical fallback and should get the same
treatment — noting that this makes the pair stricter thanupdate_user_status/3, which is the right direction to diverge.
Test quality — the honest accounting
Asked of every new test: would it still pass with the production change
reverted? Kimi answered it per test; Opus was to verify by actually reverting
and did not report.
| Test | Red on revert? |
|---|---|
| Admin may not set an Owner's password | yes — real |
| Admin may not set another Admin's password | yes — real |
| non-staff actor refused | yes — real |
| an in-rank actor succeeds | no — pins pre-existing permissive behaviour |
| changing your own password is allowed | no — same |
| omitting the actor is the system path | no — pins the fail-open default (see #3) |
Three of six actually test the change. The other three are anti-over-refusal
guards, which are worth keeping — but they should be described as that, not
counted as coverage of the new rule.
All six assert on the stored hash re-read through Repo.get!
(security_authority_test.exs:47), so a guard that refused only after writing
would fail them. That part is right.
Missing: no test that a non-%User{}:admin_user is refused (today it
writes), and none exercising the caller's handling of the new error tuple — the
crash path in #2.
The mix.exs commit (40cd3d11)
- The
envprefix fix is correct on the platforms this project supports.mix cmdshells out throughSystem.cmd/3, which execs the first word, so the
bareMIX_ENV=prodassignment did raise:enoent. On Windows there is noenvbinary andmix cmduses no shell, so the step would fail there — CI is
ubuntu-only and nothing claims Windows support, so this is a NITPICK. - Moving
hex.audit/hex.buildinto subprocesses does not weaken the
gate: same tasks, same arguments, sameMIX_ENV, same tree and lockfile.
Failure propagation is intact — the observed:enoentabort proves a failingcmdstep kills the alias. Cost is recompilation time, not coverage.
The same shape left open elsewhere in auth.ex
This PR closes one function whose authority rule lived only in its LiveView
caller. Kimi enumerated the rest; the ones that carry real weight:
update_user_profile/2(auth.ex:1597) — rewrites email/username of any
user; the rule is theMap.dropin the caller. GLM independently named this
one as the half-applied principle.update_user_fields/2(auth.ex:1808) — same fields, reachable unfiltered.
This is BLOCKING 1.admin_confirm_user/1/admin_unconfirm_user/1(auth.ex:1269,1293) —
public and ungated; unconfirming locks the target out of all elevenrequire_email_confirmationgates.set_organization/2,remove_from_organization/1,change_account_type/2
(auth.ex:968,981,990) — reachable fromuser_form.ex:336,735with no
rank rule anywhere.deliver_user_reset_password_instructions/3(auth.ex:1374) — gated only in
the event handler; impact limited (mails the target's own address).
Nitpick on the commit message
It says the live caller "gates on can_manage_credentials in all four of its
handlers". Three handlers refuse (user_form.ex:201,214,222); the fourth path —
save — does not refuse, it silently drops fields (user_form.ex:643-649).
The distinction is not cosmetic: silent-drop is exactly the shape that made
BLOCKING 1 invisible for as long as it has been.
Where the reviewers agreed and disagreed
Both Kimi and GLM independently found BLOCKING 2 — two models, two routes, same
defect, which is the strongest signal in this round. Both also converged on the
"principle applied half-way" theme, though only Kimi followed it to the
reachable custom_fields exploit. Neither disputed the core guard itself: the
argument order matches the definition, has_system_role?/2 correctly falls
through to a query when roles is %Ecto.Association.NotLoaded{}, and there is
no unsatisfiable-gate trap of the kind that bit the OAuth work.
timujinne
commented
Aug 9, 2026
Addendum — the third reviewer landed, and the record above needs correctingThe combined review I posted earlier said Opus "produced no deliverable within It independently confirmed both findings with live probes rather than by Already fixed in |
1.7.237 ships SIX PRs, not the four I had reviewed. #689 (the migration squash) and #690 (security-p1 + the #689 review fixes) merged before this session and had no CHANGELOG entry at all; #694 had none either. #689 already had a CLAUDE_REVIEW.md from an earlier pass. Appended a second pass rather than redoing it: its five mechanical findings are fixed (verified in #690), blocker #6 is still open and still needs a database, and blocker #7 — the module ecosystem being unable to resolve 2.0 — is DISSOLVED by shipping as a patch release. Re-verified all seven pins in /workspace: every one is `~> 1.7.x`, which accepts 1.7.237 and rejects 2.0.0. That matters more than it sounds, because the first pass recommended 2.0.0 and I bumped to 1.7.237 before reading it. The override was accidental, so the trade is now written down: 1.7.237 breaks no module but lets a below-floor host be dragged across the floor by a routine `mix deps.update`; 2.0.0 prevents that but makes `mix deps.get` unsatisfiable for every host running a feature module. I think 1.7.237 is right — a refused migration with a precise remedy beats a dependency resolver refusing to resolve — but only if the requirement is impossible to miss, so the CHANGELOG now LEADS with it instead of listing it. #690 reviewed clean; no new defects. Verified the pg_has_role fix (immediate query, parameterized, absent function falls through to queue) and specifically checked that the bridge_version fix reached every raise site — it does, because the :ensure_current path re-raises the existing struct. CHANGELOG now covers #689 through #694. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
admin_update_user_password/3now answers to the same rank rule as its siblingupdate_user_status/3: whencontext[:admin_user]is present, the write isrefused with
{:error, :insufficient_permissions}unlesscan_manage_user_credentials?/2allows that actor over that target.Why here and not in the caller
#686 introduced the rank rule and cited its siblings as the model; the follow-up
review then moved
update_user_status/3into the context because a rule livingin one LiveView does not live anywhere — the guard had been added to the edit
form while the user-list and user-detail pages reached the context ungated.
Credential management was left behind in that move. The edit form asks
can_manage_user_credentials?/2to hide the UI and to refuse the event, but thecontext function itself asked nothing and wrote the hash for whoever reached it.
Credentials are the more final half of the pair: setting a password also deletes
every session token of the target.
The actor was already in hand —
:admin_userhas always been threaded throughcontextfor the audit row, so only the question was missing. It now authorizesas well as audits.
Reachability
Not exploitable through the shipped UI today. The one live caller
(
user_form.ex) gates onsocket.assigns.can_manage_credentialsin all four ofits handlers. This closes the second caller before it exists, rather than a hole
in front of one.
Compatibility
Omitting
:admin_userstays the system path — seeds, migrations and mix tasksact with no actor and are not rank-checked, matching
update_user_status/3.The only behaviour change for an existing caller is that an out-of-rank actor
now gets
{:error, :insufficient_permissions}instead of a successful write.Tests
Six cases in
security_authority_test.exs, mirroring the status block. Theyassert on the stored hash, not the return tuple, so a guard that refused
only after writing would still fail them.
Verified red first: the three refusal cases returned
{:ok, %User{}}before thechange, while the three permissive cases passed unchanged — so the new block is
not vacuous.
Run manually, since CI here is
workflow_dispatchonly:test/integration/users/security_authority_test.exs— 30 tests, 0 failurestest/integration/users— 367 tests, 0 failures, noexcludedline(a database-less run reports
0 tests, 0 failures (367 excluded)and stillexits 0, so the excluded count is the thing worth reading)
mix format --check-formatted,mix compile --warnings-as-errors,mix credo --strict(10104 mods/funs) — clean@versionandCHANGELOG.mddeliberately untouched.