An administrator can give a colleague a new password - #220
Conversation
️✅ There are no secrets present in this pull request anymore.If these secrets were true positive and are still valid, we highly recommend you to revoke them. 🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request. |
There was a problem hiding this comment.
Pull request overview
Adds an administrator-driven password reset flow to the existing /claim endpoint for password-only deployments, so admins can recover colleagues who have already claimed accounts (and there is no email/self-service reset path). The new reset purpose is kept disjoint from setup by matching token purpose against the current user row state, and reset links are made single-use by binding them to a fingerprint of the credential they were minted against.
Changes:
- Extend onboarding tokens and
/claimhandling to support bothsetup(pending users) andreset(claimed users) with state-based eligibility and purpose-specific UI copy. - Implement credential-bound, single-use password reset in
user_store.reset_password, and revoke refresh tokens on successful reset. - Add comprehensive tests for reset behavior and update preview rendering to include the renamed/added pages.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_onboarding.py | Adds reset-link coverage: purpose disjointness, single-use semantics, guard enforcement, and refresh-token revocation behavior. |
| packages/agami-core/src/user_store.py | Introduces reset_password with all safety guards in SQL and credential_fingerprint used to retire reset links. |
| packages/agami-core/src/onboarding.py | Adds reset token mint/verify, unified “actionable” resolver, revised claim page wording, and offloads blocking work via run_blocking. |
| packages/agami-core/src/oauth_server.py | Adds revoke_refresh_tokens_for helper used to end refresh-based sessions after a reset. |
| dev/render_previews.py | Updates renamed onboarding page preview calls and adds reset-page previews. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # The account status agami-core considers live; mirrored from `user_store._ACTIVE` so the handler and | ||
| # the WHERE cannot disagree about what "switched off" means. | ||
| _ACTIVE_STATUS = "active" |
There was a problem hiding this comment.
Fixed in d844bb8 — and this was the good catch of the three.
You're right that the comment described a guarantee the code didn't provide: it said "mirrored from user_store._ACTIVE" while actually being a second copy of the literal. That matters more here than it normally would, because a disagreement between those two layers is precisely the defect the previous commit fixed — the handler checking less than the store, so an ineligible account got a 200 and a password form and only the write refused. Leaving a way for them to drift, in the constant that decides "switched off", is that same bug waiting on a rename.
ACTIVE_STATUS is public now and both sides read it. Proved rather than asserted: changing it to "live" leaves all 23 tests passing, which only happens if the handler and the SQL are genuinely reading one value.
| write("15-reset-password.html", onboarding.claim_page_html("eyJhbGciOi.SAMPLE.xyz", "reset")) | ||
| write("16-reset-done.html", onboarding.claim_done_html(BASE, "reset")) |
There was a problem hiding this comment.
Fixed in d844bb8 — real collision, 16-model-overview.html already had that slot. Renumbered both reset pages to 22/23 so they sit together at the end rather than interleaving with the model flow, and checked the whole set: 15 previews, no colliding numbers left.
eb725fc to
dbec4fa
Compare
The setup link works exactly once — claiming flips the account out of
`pending`, so every later use is refused. An administrator could therefore
create a colleague's account and never help them back into it, and with no
mail path there is no self-service reset either. A forgotten password had no
route short of editing the database by hand.
`/claim` gains a second purpose. `setup` still means a pending account
choosing its first password; `reset` means a claimed account being given a
new one because an administrator asked. The two are strictly disjoint —
matched against the state of the row, not trusted from the token — so a setup
link still cannot touch a claimed account.
**Single-use needs its own mechanism here.** A setup token retires itself
because claiming flips the account out of pending; a reset leaves the account
exactly as claimed as it was, so nothing about the row changes and the link
would work forever. The token carries a one-way marker of the credential it
was minted against, and the credential is a condition of the UPDATE — so the
first successful reset retires it, a password change by any other route
retires it, and two simultaneous posts cannot both win.
A reset never gives a password to a provider-bound identity, never revives a
switched-off account, and revokes that principal's refresh tokens. What it
does NOT end is written down rather than implied: the /admin cookie is a
stateless 12h JWT nothing here revokes, an outstanding authorization code
survives its short window, and a live access token lasts until it expires.
Every guard is mutation-checked: removing any one turns a test red. That bar
is what found the real defects, and there were several. The handler checked
less than the store, so a token for a provider-bound account got a 200 and a
password form while only the write refused — a state oracle, and, because
such a token could never spend, an unauthenticated argon2 lever measured at
45x a rejected token on an endpoint with no rate limit. Argon2 also ran
inline on the event loop in an async handler and now goes through
`run_blocking`. A passwordless row fingerprinted to sha256("") — one public
constant — and now raises. The password write and the session revocation
share one transaction.
It also found two tests passing for the wrong reason: an ordering test that
returned before reaching the code it named, and a wrong-purpose check that
had migrated onto a verifier the handler no longer called, so an
`admin_session` JWT could have acted as a setup link with the suite green.
Review of the review: the active-status constant was a second copy of the
string under a comment claiming it was shared, which is the same drift class
this change exists to fix. It is public and shared now, proved by changing it
to "live" and watching every test still pass.
Squashed to one commit so no commit on this branch carries the
credential-shaped test literal the secret scan flagged; it is an English
phrase in a named constant now, the convention `test_member_onboarding.py`
already states.
Spec: ACE-108
dbec4fa to
570e2c5
Compare
Summary
The setup link works exactly once — claiming flips the account out of
pending, so every later use is refused. An administrator could therefore create a colleague's account and never help them back into it, and with no mail path there is no self-service reset either. A forgotten password had no route short of editing the database by hand./claimgains a second purpose.setupstill means a pending account choosing its first password;resetmeans a claimed account being given a new one because an administrator asked. The two are strictly disjoint — matched against the state of the row, not trusted from the token — so a setup link still cannot touch a claimed account.The minting surface is not in this PR. This is the accept half; the authorization for who may reset whom lands in the private product's console, which is the only consumer today. Reviewers should read
mint_reset_tokenas a library entry point with its authorization deliberately delegated, and that delegation is stated in its docstring.Changes
onboarding.py— theresetpurpose, a_actionableresolver that matches purpose against the row's state, and per-purpose page wording.setup_page_html/setup_done_htmlare nowclaim_page_html/claim_done_html.user_store.py—reset_password, with every guard in the WHERE including the credential it was minted against;credential_fingerprint, which raises rather than fingerprinting nothing.oauth_server.py—revoke_refresh_tokens_for.dev/render_previews.py— the renamed pages, plus previews of the two reset variants.How a reset link is single-use
A setup token retires itself because claiming flips the account out of
pending. A reset leaves the account exactly as claimed as it was, so nothing about the row changes and the link would work forever. The token instead carries a one-way marker of the credential it was minted against; the write is conditioned on that credential still being the stored one. The first successful reset retires it — and so does a password change by any other route.What it does not do
Stated in the module docstring rather than left to be assumed: a reset does not end the
/adminconsole session (a stateless 12h cookie nothing here revokes — only relevant when the account reset is the configured operator admin), does not invalidate an outstanding authorization code, and does not instantly kill a live access token. Refresh tokens are revoked, so the longest a stale session outlives a reset is one access-token lifetime.Review findings, all closed in bd2a55d
Two passes ran; between them they found nine things, and the first commit shipped all of them:
asynchandler; it now goes throughrun_blocking.sha256(""), a public constant. It raises instead.dev/render_previews.pycalled two renamed functions and would have died immediately.7–9. Copy and docstring accuracy, plus the two toothless tests below.
Two of my tests passed for the wrong reason, both proven by mutation. The ordering test returned on the length check before reaching the code it named; and the wrong-purpose rejection had migrated onto a verifier the handler no longer called, so an
admin_sessionJWT could have acted as a setup link with the suite green.Checklist
Spec:line presenttests/test_onboarding.py, up from 9you@example.com,sso@example.com,both@example.com), no customer or internal namingtest_hosted_instruction_truth.pyand identical on cleanmain(4502 passed, same 7)Spec: ACE-108