Skip to content

An administrator can give a colleague a new password - #220

Merged
ashwin-agami merged 1 commit into
mainfrom
password-reset-link
Aug 11, 2026
Merged

An administrator can give a colleague a new password#220
ashwin-agami merged 1 commit into
mainfrom
password-reset-link

Conversation

@ashwin-agami

Copy link
Copy Markdown
Contributor

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.

/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.

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_token as a library entry point with its authorization deliberately delegated, and that delegation is stated in its docstring.

Changes

  • onboarding.py — the reset purpose, a _actionable resolver that matches purpose against the row's state, and per-purpose page wording. setup_page_html/setup_done_html are now claim_page_html/claim_done_html.
  • user_store.pyreset_password, with every guard in the WHERE including the credential it was minted against; credential_fingerprint, which raises rather than fingerprinting nothing.
  • oauth_server.pyrevoke_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 /admin console 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:

  1. The handler checked less than the store. A token for a provider-bound account got 200 and a password form; only the submit refused. Also a state oracle via GET 200 vs 400.
  2. That gap was an unauthenticated CPU lever — such a token could never spend, so it stayed valid for three days while every POST paid for argon2, measured at 45× a rejected token. Argon2 was also running inline on the event loop in an async handler; it now goes through run_blocking.
  3. Single-use was check-then-act across two connections — two simultaneous posts both wrote, and the later password won. The credential is now a condition of the UPDATE.
  4. A passwordless row fingerprinted to sha256(""), a public constant. It raises instead.
  5. The write and the revocation now share one transaction.
  6. dev/render_previews.py called 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_session JWT could have acted as a setup link with the suite green.

Checklist

  • Spec: line present
  • ruff clean, formatted
  • Tests added — 23 in tests/test_onboarding.py, up from 9
  • Every guard mutation-checked: removing any one turns a test red. This is the bar the spec is held to, not test count — it is how findings 1–5 and the two weak tests were found.
  • No test weakened or deleted; all five existing setup-path tests unchanged and passing
  • Public-repo safe: synthetic addresses only (you@example.com, sso@example.com, both@example.com), no customer or internal naming
  • Full suite: 4516 passed, 7 failed — the 7 are pre-existing in test_hosted_instruction_truth.py and identical on clean main (4502 passed, same 7)

Spec: ACE-108

Copilot AI lite review requested due to automatic review settings August 10, 2026 10:50
@gitguardian

gitguardian Bot commented Aug 10, 2026

Copy link
Copy Markdown

️✅ 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.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /claim handling to support both setup (pending users) and reset (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.

Comment thread packages/agami-core/src/onboarding.py Outdated
Comment on lines +74 to +76
# 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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/test_onboarding.py Outdated
Comment thread dev/render_previews.py Outdated
Comment on lines +502 to +503
write("15-reset-password.html", onboarding.claim_page_html("eyJhbGciOi.SAMPLE.xyz", "reset"))
write("16-reset-done.html", onboarding.claim_done_html(BASE, "reset"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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
@ashwin-agami
ashwin-agami merged commit b21438c into main Aug 11, 2026
8 checks passed
@ashwin-agami
ashwin-agami deleted the password-reset-link branch August 11, 2026 05:05
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants