Repository navigation
feat(admin): secure bootstrap CLI, admin invites, and account deactivation - #141
Conversation
…ation Admin accounts were "seeded or invited" on paper but neither path existed — the preview admin was recreated by hand with SQL against the Render database, and there was no way to shut down a compromised or departed account without deleting rows. Adds a create-admin CLI that reads credentials from stdin (never argv, so they stay out of ps/history/CI logs) and refuses to run when an admin already exists unless --force is passed. Invites are hashed, expiring and single-use, redeemed into a session at /auth/signup-with-invite. Deactivation soft-flags the account and invalidates its sessions: login, refresh and already-issued access tokens all stop working, the last active admin is protected, and no clinical or payment record is ever removed. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@techAlhaji Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
@techAlhaji pls fix CI |
|
CI is failing on this PR. Typecheck fails: Please push a fix and CI will re-run. Before pushing, run |
|
This PR has merge conflicts with |
The four "Merge branch 'main'" commits on this PR silently resolved conflicts in main's favour, deleting the issue DocForum#39 feature code while keeping its tests and routes: the AdminInvite model and User deactivatedAt/sessionsInvalidatedAt columns vanished from schema.prisma (their migration stayed), signupWithInvite / assertActiveSession / isSessionRevoked / signupWithInviteSchema / config.adminInvites were dropped, and the /admin adminRouter mount was replaced by the audit router. Restores all of it on top of main's current code, plus the two Prisma 7 adaptations the merge could not know about: create-admin.mjs now builds its client with the PrismaPg driver adapter (a bare PrismaClient throws in v7), and the post-merge integration test verifies the patient's email before booking, because main's DocForum#34 gate now guards that endpoint. Also defers the fresh login in the deactivation test past the second-precision iat boundary, which made the test flaky under load. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
…ooks The integration hooks do more than a single test — start an embedded Postgres, run `prisma migrate deploy`, import the app, seed actors — and several such files run in parallel. Vitest's 10s default hook timeout cut the access-control seed off mid-flight on the CI runner (101 tests skipped), even though testTimeout was already raised to 15s for exactly this class of slowness. 30s matches the same reasoning. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
access-control, payments-bigint and admin-lifecycle all bound port 5438. Two concurrent embedded Postgres clusters on one port hang each other's beforeAll until vitest's hook timeout kills the file — exactly what the CI run showed (access-control: hook timed out, 101 tests skipped). The collision existed between the first two on main; adding the restored admin-lifecycle file made it reliable. admin-lifecycle moves to 5439, payments-bigint to 5440 — each integration file now owns a port. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
What changed
Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation (Phase 8). Closes #39.
Admin accounts are "seeded or invited" according to the roadmap, but neither path actually existed — the preview admin was recreated by hand with
UPDATE "User" SET role = 'admin'against the Render database, and there was no way to shut down a compromised or departed doctor/facility/admin account without deleting rows.1.
backend/scripts/create-admin.mjs— the supported bootstrap pathps, shell history and CI logs record command lines, so the password is read from stdin — silently, when there's a TTY. Passing--password/--password-hashis refused rather than quietly ignored (the email is accepted as--email: an address isn't a secret).--forceis passed, so a stray run can't quietly add a second administrator.emailthenpassword(no confirmation line); interactive mode prompts for all three.2. Admin invites
AdminInvitemodel (migration20261015175817_add_admin_lifecycle): SHA-256token_hashonly,expiresAt,consumedAt. The raw token is never persisted and never logged.POST /admin/invites(admin-only) →201 { id, email, expiresAt, token, inviteUrl }. The token is returned once; there's no email provider yet (issue [Phase 6] ADR: pick notification provider (email/SMS) #17), so the inviting admin passes the link on out of band. Issuing a new invite for the same address retires the outstanding one (in one transaction,consumedAt-marked, never deleted).POST /auth/signup-with-inviteredeems it and signs the new admin in. Consuming the invite and creating the account happen in a single transaction, with the consume as a conditionalUPDATE ... WHERE consumedAt IS NULL, so two concurrent redemptions can't both mint an admin.409if the address already has an account,403if the invite belongs to a different address,400for unknown/used/expired tokens.usersmodule's business and the session isauth's, soauthService.signupWithInvitecallsusersService.redeemAdminInvite(service-to-service, hard rule 4) and never touchesAdminInvitedirectly.FacilityProfilename/type at redemption, so folding them in here would have mixed two account shapes into one endpoint — noted rather than half-built.3. Deactivation
User.deactivatedAt+User.sessionsInvalidatedAt;PATCH /admin/users/:id/deactivateand/reactivate(both admin-only, both idempotent).refresh(), andauthenticatenow callsassertActiveSession(userId, iat)— a per-request DB read that rejects a deactivated account and any token minted before the account's sessions were invalidated. Without it an access token would keep working for the rest of its 15-minute TTL./reactivatebumpssessionsInvalidatedAttoo, so a token from before the deactivation doesn't come back to life when the account does (the integration test asserts exactly that). Tokens with noiatfail closed.409(the admin console is the only way back in).403for a deactivated login is returned after the password check, so it can't be used to probe which addresses exist. Console audit lines record who deactivated whom (noAuditEventtable yet — known gap — so that's the interim record).4. Docs, including the runbook this replaces
scripts/README.mddocuments the CLI (the issue's DoD) and ADR 0003's renewal runbook step 6 now uses it instead of the hand-written SQL promotion.Assumptions (issue didn't specify)
ADMIN_INVITE_TTL_HOURS), accept URL configurable (ADMIN_INVITE_ACCEPT_URL) — docforum-web owns that page.PATCH /admin/users/:id/deactivateon an already-deactivated user returns200unchanged rather than erroring (idempotent).Integration note for #34 (email verification)
PR #34 adds
User.emailVerifiedAt, which doesn't exist on main, so the CLI and the invite redemption here can't mark a fresh admin verified yet. Both places carry aTODO(issue #34)and the PR that merges second should create bootstrap/invited admins as verified — otherwise a new admin can't call the payment endpoints that #34 gates. Flagging it rather than letting it surface after both land.Which ROADMAP.md item does this close/progress?
Phase 8 — "Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation", issue #39.
Docs updated in this PR
AdminInvite; §3usersmodule scope)/auth/signup-with-invite+/admin/*section)ADMIN_INVITE_*)Test output
npm run typecheckclean.The CLI test runs the real script as a subprocess against the test database (password piped on stdin,
--force, and argv-password refusal), so the DoD's "secrets never appear in process args or logs" is verified by execution rather than inspection. The new integration file uses its own embedded Postgres on port 5438, so it runs in parallel with the other integration files; no change tovitest.config.mts.Closes #39
Note on parallel Phase 8 PRs
#34, #35 and #37 touch the same auth module. This PR touches
app.ts,config,middleware/auth.ts,auth/{routes,controllers,services,validation}and the wholeusersmodule. Overlap with the others is mostly additive (a route registration line, a claim on the token payload, ARCHITECTURE.md §4.1/§6,docs/api/README.md), so a small rebase may be needed if one of those merges first — plus theTODO(issue #34)integration note above. This PR deliberately does not touchhttp-error.tsormiddleware/csrf.ts.