Skip to content

fix(auth): make account provisioning invitation-only - #290

Open
nourshoreibah wants to merge 4 commits into
mainfrom
fix/auth-invitation-only
Open

fix(auth): make account provisioning invitation-only#290
nourshoreibah wants to merge 4 commits into
mainfrom
fix/auth-invitation-only

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Follow-up to #289. That PR merged before this fix was pushed, so it lands separately.

The problem

POST /auth/register is public and unauthenticated, and when no branch.users row 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: false suggests. Four list endpoints authorize on isAuthenticated alone with no project scoping:

  • GET /donors → every donor
  • GET /donations → every donation
  • GET /expenditures → every expenditure
  • GET /reports → every report

So 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.users row — only claim one an admin already approved. The insert path is deleted.

  1. Admin creates the invitation via the ADMIN-gated POST /users — a row with cognito_sub = NULL.
  2. The invitee POST /auth/registers with that email, which claims the row (setting cognito_sub, never touching is_admin).
  3. POST /auth/verify-email with the emailed code, then POST /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: authenticateRequest rejects any Cognito identity whose sub has no branch.users row, so an identity created out of band is inert. Locking down the pool would additionally break the SignUp call that claim-on-register depends on. AdminCreateUser remains a valid invitation path — it yields NEW_PASSWORD_REQUIRED, which the login page already handles.

No enumeration oracle: the 403 is deliberately identical whether or not the address exists, so /register can't be used to discover staff emails.

Tests

apps/backend/lambdas/auth: 66 unit tests pass, tsc clean. New coverage:

  • an uninvited email is refused before Cognito is called — no Cognito user, no DB row, nothing written
  • the 403 message reveals nothing about whether the address is known
  • claiming still preserves user_id and is_admin, and never writes is_admin

Three 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).

⚠️ The e2e suite was not run locally — it hardcodes 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

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>
@github-actions

Copy link
Copy Markdown
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 nourshoreibah added the no-review The PR review bot won't run label Jul 28, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review July 28, 2026 01:55
@github-actions

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Collaborator Author

@copilot resolve the merge conflicts in this pull request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants