fix(auth): make account provisioning invitation-only - #290
Open
nourshoreibah wants to merge 4 commits into
Open
Conversation
POST /auth/register is public and unauthenticated, and when no branch.users row matched the email it inserted one. Anyone on the internet could therefore create a working account. That predates this branch, but claim-on-register made the invitation path work without closing the open one. It mattered more than `is_admin: false` suggests. Four list endpoints authorize on `isAuthenticated` alone with no project scoping -- GET /donors, /donations, /expenditures and /reports -- so a self-registered stranger could read the full donor list and financial history. Registration can no longer create a branch.users row, only claim one an admin already approved via the ADMIN-gated POST /users. An email with no pending invitation gets 403 INVITATION_REQUIRED, and the insert path is gone. The row is the real control rather than the pool configuration: authenticateRequest rejects any Cognito identity whose sub has no row, so an identity created out of band stays inert. The 403 is deliberately identical whether or not the address exists, so the endpoint cannot be used to enumerate staff emails. Also update the e2e tests, whose premises this invalidated: two inserted rows without a cognito_sub, which now reads as a pending invitation rather than a conflict, and one created an account from nothing. Read scoping on those four endpoints is deliberately left to a separate PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
|
Database Types Check Complete The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones. No changes were needed and the type definitions are already up to date. |
nourshoreibah
marked this pull request as ready for review
July 28, 2026 01:55
Contributor
|
Database Types Check Complete The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones. No changes were needed and the type definitions are already up to date. |
Collaborator
Author
|
@copilot resolve the merge conflicts in this pull request |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #289. That PR merged before this fix was pushed, so it lands separately.
The problem
POST /auth/registeris public and unauthenticated, and when nobranch.usersrow matched the email it inserted one. Anyone on the internet could create a working account.This predates #289 — but #289's claim-on-register work made the invitation path function without closing the open one, so it's mine to finish.
It matters more than
is_admin: falsesuggests. Four list endpoints authorize onisAuthenticatedalone with no project scoping:GET /donors→ every donorGET /donations→ every donationGET /expenditures→ every expenditureGET /reports→ every reportSo a self-registered stranger could read the full donor list and financial history of a non-profit.
The fix
Registration can no longer create a
branch.usersrow — only claim one an admin already approved. The insert path is deleted.POST /users— a row withcognito_sub = NULL.POST /auth/registers with that email, which claims the row (settingcognito_sub, never touchingis_admin).POST /auth/verify-emailwith the emailed code, thenPOST /auth/login.An email with no pending invitation gets
403 INVITATION_REQUIRED; an already-claimed one still gets 409.Why the DB row rather than the Cognito pool is the control:
authenticateRequestrejects any Cognito identity whosesubhas nobranch.usersrow, so an identity created out of band is inert. Locking down the pool would additionally break theSignUpcall that claim-on-register depends on.AdminCreateUserremains a valid invitation path — it yieldsNEW_PASSWORD_REQUIRED, which the login page already handles.No enumeration oracle: the 403 is deliberately identical whether or not the address exists, so
/registercan't be used to discover staff emails.Tests
apps/backend/lambdas/auth: 66 unit tests pass,tscclean. New coverage:user_idandis_admin, and never writesis_adminThree e2e cases were invalidated by this change and are rewritten: two inserted rows without a
cognito_sub, which now reads as a pending invitation rather than a conflict (they now insert a claimed row so they still exercise 409 and email normalisation), and one created an account from nothing (now asserts the gate).localhost:3000, which is occupied on my machine, so it 404s rather than connecting. All four cases typecheck; please confirm they pass in CI on a clean runner.Deliberately not included
Project scoping on those four list endpoints. Closing registration removes the anonymous path to that data, but the scoping gap is real and deserves its own PR.
🤖 Generated with Claude Code