Skip to content

🏗️ PUT-1745: enforce the forced password change on workspace seats - #3737

Open
jfcastro92 wants to merge 1 commit into
juancastro/put-1761-45-tell-the-members-when-something-is-shared-with-thefrom
juancastro/put-1745-forced-password-change
Open

🏗️ PUT-1745: enforce the forced password change on workspace seats#3737
jfcastro92 wants to merge 1 commit into
juancastro/put-1761-45-tell-the-members-when-something-is-shared-with-thefrom
juancastro/put-1745-forced-password-change

Conversation

@jfcastro92

Copy link
Copy Markdown
Collaborator

Closes PUT-1745. Stacked on #3735 (phase 4).

The gate this ticket owns

user.requires_password_change shipped in PUT-1699 and has been written but never enforced. Provisioning sets it; reissueCredential reads it as the "has this seat activated" test. gates.ts checks requires_email_confirmation, requires_phone_verification and requires_card_verification — there was no fourth check.

Verified live during phase 2 validation, and it is worse than "the flag does nothing":

POST /send-confirm-email  -> 200        (a seat can self-serve this)
POST /confirm-email       -> 200
/whoami /teams /stat /mkdir -> all 200  with requires_password_change still 1

The flag was also only ever written as 1, never cleared, anywhere in src/backend. So two things followed: the temporary password the administrator generated stayed valid forever, and reissueCredential's "already activated" 409 was unreachable — an administrator could mint a working credential for any seat, indefinitely.

What this adds

The fourth clause in assertVerifiedAccount, and nowhere else. Its docblock records that WebDAV once bypassed the phone and card gates by dispatching off a single router.use; a second implementation repeats a documented mistake.

The clearing path, because the gate alone would have locked every seat out permanently. handleChangePassword and the pass_recovery_token path both clear the flag and the expiry, and record an activate audit row.

allowUnconfirmed: true on /user-protected/change-password — the obvious trap is that a gate refusing everything also refuses the endpoint that clears it.

⚠ That flag is coarser than the ticket's wording: it disables the whole requireVerifiedAccount gate on that route, so a pending phone or card verification is also let through, not just email. Benign for the same reason — the caller is authenticated and proving their current password — but broader than described, and it changes a shared route.

A 24-hour expiry on the temporary password (user.temp_password_expires_at, three dialects: 0080 / mysql_mig_35 / postgres_mig_24), so an unused reset dies rather than becoming a standing credential. Login refuses an expired one with temporary_password_expired.

POST /teams/:uid/members/:username/password-reset — 20/day, audited as reset_member_password with no credential in the row, drops the member's sessions, leaves 2FA alone. reissueCredential now writes an audit row too, and its 409 is reachable.

New limit documented in rate-limits-and-quotas.md, same change.

Why this is on its own

It is the only phase 5 change that touches shared platform code, and it does so twice: gates.ts runs on every authenticated request for every account on the platform, and there is a three-dialect migration. Both want isolated review and an isolated revert. The member-facing half is the PR stacked on this one.

Verification

Typecheck clean. Full backend suite 7765 passed | 26 skipped.

Falsified — each piece broken, the failing tests confirmed, then restored:

Broke Failed
the gate clause in assertVerifiedAccount 3 — exactly the gate tests
flag clearing in handleChangePassword 3
allowUnconfirmed on change-password 3
login expiry check 1
expiry write in #issueTemporaryPassword 1
reset audit append 2
reissue audit append 1
recordPasswordSelfChange 1
isTemporaryPasswordExpired 3

One test deliberately passes when its neighbour breaks: dropping the expiry write does not fail the HTTP login-expiry test, because that test writes the expiry itself. That split is intended — one test proves the write, the other proves enforcement — and each fails when its own code is broken.

Decisions worth a reviewer's eye

  • Reset drops the member's sessions. Not in the ticket; judged correct for a credential change and consistent with #suspend. Easy to remove.
  • reissue and reset share the reset_member_password audit action (reissue carries reason: 'reissue'). From the member's side both mean the administrator holds a working credential.
  • requires_password_change is smallint on postgres while the other three requires_* flags are boolean — an inconsistency inherited from PUT-1699. It is therefore not in USER_BOOLEAN_COLUMNS; writes stay 0/1. Worth a follow-up to align the column type.

Not done here

The forced-change screen naming who reset it and when is GUI, so phase 6. The 403 body carries no actor or timestamp today; if the screen needs them, that is a follow-up on this route.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.83%
⬆️ +0.04%
29077 / 30987
🔵 Statements 91.85%
⬆️ +0.03%
31571 / 34370
🔵 Functions 90.58%
⬆️ +0.05%
5356 / 5913
🔵 Branches 80.77%
⬆️ +0.02%
20867 / 25834
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/clients/database/SqliteDatabaseClient.ts 89.14%
🟰 ±0%
57.45%
🟰 ±0%
81.6%
🟰 ±0%
95.58%
🟰 ±0%
6, 9, 13, 16, 24-26, 32, 33, 34, 38-39, 44, 45, 46, 47, 48, 244-246, 494-497, 512
src/backend/clients/email/templates.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/auth/AuthController.ts 90.76%
⬇️ -0.03%
79.29%
⬆️ +0.04%
77.5%
⬇️ -0.46%
91.69%
⬇️ -0.04%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 479-481, 495-497, 632-633, 707-709, 726-728, 870, 932-936, 954, 996-1001, 1010, 1021, 1036-1041, 1047, 1091-1092, 1095-1100, 1243, 1274-1275, 1294-1298, 1336-1338, 1340-1342, 1390, 1418-1429, 1504-1506, 1519, 1579-1581, 1615, 1628-1630, 1656-1658, 1689, 1693-1695, 1793, 1879, 1894, 1933, 1946, 1991, 2050, 2063-2066, 2108-2110, 2128-2131, 2185-2187, 2199-2201, 2217-2219, 2233-2236, 2242, 2261, 2272, 2394-2398, 2427, 2458-2460, 2472-2478, 2538-2543, 2622-2624, 2687, 2725, 2766-2768, 2834, 2864, 2943-2945, 2947-2949, 2953-2956, 2969-2972, 3037-3042, 3045-3047, 3096, 3114, 3132, 3145, 3168, 3180, 3197, 3207, 3218, 3272, 3405-3409, 3431-3433, 3447, 3450-3454, 3530
src/backend/controllers/team/TeamController.ts 78.3%
⬆️ +5.93%
43.33%
🟰 ±0%
70%
⬆️ +6.74%
81.16%
⬆️ +7.19%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 196-207, 215, 259, 279-291, 304-311, 327-329, 342-344, 349-351, 358-360
src/backend/core/http/middleware/gates.ts 98.55%
⬆️ +0.03%
98.79%
⬆️ +0.03%
100%
🟰 ±0%
98.49%
⬆️ +0.02%
39-46
src/backend/services/team/TeamService.ts 84.76%
⬇️ -0.11%
72.05%
⬇️ -0.39%
95.65%
⬆️ +0.42%
87.12%
⬆️ +0.49%
87-88, 108-109, 130, 198-200, 203-205, 214-226, 255-258, 280-291, 341-344, 352, 358, 395-398, 450-452, 506, 590-593, 690, 709-711, 730, 737, 743
src/backend/stores/user/UserStore.ts 93.8%
🟰 ±0%
86.33%
⬆️ +1.09%
93.02%
🟰 ±0%
96.27%
🟰 ±0%
17, 19, 124, 306, 401, 429, 512, 517, 532, 550-552, 555, 557
src/backend/util/temporaryPassword.ts 100% 100% 100% 100%
Generated in workflow #1597 for commit b7421ac by the Vitest Coverage Report Action

@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation

Engine: mysql 8 · teams_enabled: true · a real workspace, a real provisioned seat, driven over HTTP.

The migration applies

[mysql] 35 migration files applied
[mysql] applied mysql_mig_35.sql (1 statements)
user.temp_password_expires_at present: 1

Provisioning now writes both the flag and the expiry:

username   req_pw  has_expiry
p5s6740      1         1

The gate — the phase 2 finding, closed

That finding was: a seat signs in on its temporary password, self-serves email confirmation, and reaches everything with requires_password_change still 1.

Testing this needs care, because the email gate masks the password gate. Straight after provisioning, /teams refuses with "Please confirm your email to continue" — that is the third clause, not the new one. Reading that as a pass would prove nothing. /whoami also stays 200 throughout, because it opts out via allowUnconfirmed.

So the seat's email was confirmed first, leaving the new clause as the only thing standing:

POST /confirm-email -> {"email_confirmed":true}

/teams   403  {"error":"Please choose your own password to continue"}
/stat    403
/mkdir   403

code: password_change_required

Compare the same four routes in phase 2: /whoami /teams /stat /mkdir were all 200.

Sign-in itself still succeeds, which is the design — the member is in, and can do nothing until they choose a password.

The endpoint that clears it is reachable through the gate

The trap this ticket names: a gate refusing everything would also refuse the way out.

POST /user-protected/change-password -> 200  "Password successfully updated."

username   req_pw  expiry_cleared
p5s6740      0          1

Both the flag and the expiry clear. The seat then has the product:

/whoami 200   /teams 200   /stat 200   /mkdir 200

The 409 that was previously unreachable

Because the flag was never cleared, reissueCredential's "already activated" branch could never be taken. Now:

POST /teams/:uid/members/:username/activation
  -> 409 {"error":"That account is already activated","code":"conflict"}

The reset route, and its audit row

POST /teams/:uid/members/:username/password-reset
  -> 200 {"temporary_password": "<redacted len=16>"}

The audit trail, read from the table:

id  user_id_keep  action                  reason
35      100       reset_member_password   NULL
34      100       activate                NULL     <- written when the seat chose its own password
33      100       provision               NULL

No credential in any row, and activate records that the forced change actually happened — the state transition that had no representation before.


Full suite 7766 passed | 26 skipped, typecheck clean.

Not exercised here: the 24-hour expiry lapsing in real time. isTemporaryPasswordExpired and the login refusal are unit-covered and falsified; waiting out the window live was not practical, and moving the clock would have tested the harness rather than the code.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from 3dafab4 to c342b17 Compare September 4, 2026 03:23
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from c342b17 to a1f32a6 Compare September 4, 2026 03:42
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (6d7be8bd6), which landed the KV share-grant series (#3690#3693).

One collision worth naming: those commits took migration 0080 / mysql_mig_35 / postgres_mig_24 — the exact three numbers this stack's temp_password_expires_at migration held. Renumbered to 0081 / mysql_mig_36 / postgres_mig_25, registry entry [76, ['0081_temp-password-expiry.sql']], and CURRENT_SCHEMA_VERSION is now 77. Main's three files are byte-identical to what it merged; only ours moved.

Verified per commit rather than at the tip — each of the 9 typechecks on its own, so no intermediate commit is broken. Full suite 7883 passed | 26 skipped.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from a1f32a6 to 78ae619 Compare September 4, 2026 04:31
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from 78ae619 to 973d24e Compare September 4, 2026 13:35
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Terminology: "workspace" is now "team", everywhere

The feature said puter.teams in the namespace and "workspace" in every string, comment and error code. That divergence was deliberate at design time; it is now settled the other way, and the whole stack says team.

Applied to every commit rather than added on top, so each PR shows its own code already named correctly and no intermediate commit references a name a later one changes. All 13 commits still typecheck individually.

The contract-bearing renames

These were free to change now and expensive after release, which is why they went in this pass:

Before After
not_the_workspace_owner not_the_team_owner
workspace_limit_reached team_limit_reached
workspace_deleted team_deleted
max_workspaces_per_user max_teams_per_user
max_seats_per_workspace max_seats_per_team
disabled_by_workspace disabled_by_team
createWorkspace / updateWorkspace / deleteWorkspace createTeam / updateTeam / deleteTeam

Plus twoWorkspaces.tstwoTeams.ts, shareWorkspaces.jsshareTeams.js, the teams-workspace-* CSS classes, and the team_workspace_deleted email template, which the mechanical pass turned into the stutter team_team_deleted and is now team_closed.

Scoped, and checked

"workspace" means other things in this repo. These were deliberately left alone, and verified untouched:

  • kvShares.ts and the KV integration tests, where workspace:abc: is a key prefix in an example, not a team
  • Events.md and KV.md, which use it the same way
  • UIPermissionDialog tests, same
  • package.json / webpack/BaseConfig.cjs / .gitignore, where it is npm workspaces

Audited three ways: every changed line mentions team or workspace (no collateral edits), no stutters or mangled words (team_team, teamspace, …), and both old config keys are gone with the new ones consistent across all 12 sites that read them.

One thing not renamed

The git branch names still say workspace (put-1740-1762-workspace-gui). Renaming a branch changes a PR's head ref and closes the PR, so they stay as they are — they are refs, not product surface.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from 973d24e to 1ed9fb2 Compare September 4, 2026 14:31
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from 1ed9fb2 to a06cb6d Compare September 4, 2026 15:06
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from a06cb6d to c22ea9a Compare September 4, 2026 15:27
`user.requires_password_change` shipped with the team columns but nothing
enforced it and nothing ever cleared it, so a provisioned seat kept its
administrator-issued password indefinitely and `reissueCredential`'s
"already activated" 409 was unreachable.

Adds the fourth clause to `assertVerifiedAccount`, the only place a
verification gate may live -- WebDAV builds its own actor and calls that
function directly, so a second implementation would bypass it the way the
phone and card gates once were bypassed.

A gate that refuses everything also refuses the endpoint that clears it,
so `/user-protected/change-password` opts out with `allowUnconfirmed`.
That widens the route: an account pending email, phone or card
verification can now change its password, which it could not before. The
caller is authenticated and proves the current password, so this is
benign, but it is a behaviour change to a shared route.

Also here, because the gate is worthless without them:

- change-password and the recovery-token path clear the flag, and record
  an `activate` entry when the account is a seat.
- Reset takes a live account back with a fresh credential, capped at 20
  per day and audited as `reset_member_password` with no credential in
  the row. Re-issue is audited the same way; it stays closed once a seat
  has chosen its own password.
- An issued credential expires after 24h (new `temp_password_expires_at`
  column, three dialects) and login refuses it after that, so an unused
  reset dies instead of becoming a standing credential.
- 2FA is untouched by a reset, so a reset alone is not takeover.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1745-forced-password-change branch from c22ea9a to b7421ac Compare September 4, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant