Decision (locked)
Adopt admin-invite provisioning via Cognito AdminCreateUser. There is no open self-signup — only an admin creates accounts. This keeps Cognito as the single identity source and eliminates the orphaned-row state described below.
Background / the bug this fixes
authenticateRequest (shared/lambda-auth/src/authenticate.ts:50) looks users up in branch.users by cognito_sub and returns isAuthenticated:false when there is no match.
- Self
POST /register (auth lambda) creates a Cognito user and a branch.users row with cognito_sub. Works — but is open signup, which we don't want.
- Admin
POST /users (users/handler.ts:221) inserts a branch.users row with no Cognito account and null cognito_sub → that user can never authenticate.
Key fact that makes this clean: AdminCreateUser returns the new user's sub in User.Attributes. So we can store cognito_sub at creation time — no post-confirmation trigger and no email-linking step required.
Implementation
1. Backend — users lambda: POST /users (admin only)
- Keep the existing
ADMIN authorization gate.
- Validate body (
email, name, isAdmin).
- Call Cognito
AdminCreateUser:
DesiredDeliveryMediums: ['EMAIL'] (sends the invite email + temporary password).
UserAttributes: email, email_verified: true, name.
- Let Cognito generate the temp password (omit
TemporaryPassword).
- Read
sub from the AdminCreateUser response User.Attributes.
- Insert the
branch.users row with cognito_sub = that sub, plus email, name, is_admin.
- Rollback (mirror the
auth register flow): if the DB insert fails, call AdminDeleteUser to keep Cognito and the DB in sync.
- Handle duplicate email: if
AdminCreateUser throws UsernameExistsException, return 409.
2. Backend — auth lambda
- Remove / disable open
POST /register (no public signup).
POST /login must handle the NEW_PASSWORD_REQUIRED challenge — invited users are created in FORCE_CHANGE_PASSWORD state and must set a password on first login. Implement the RespondToAuthChallenge (NEW_PASSWORD_REQUIRED) exchange, returning the challenge to the client and accepting the new password.
3. Infra — infrastructure/aws/cognito.tf
- Add an
admin_create_user_config block with allow_admin_create_user_only = true (disables public sign-up) and an invite email template (invite_message_template).
- IAM: the
users lambda execution role needs cognito-idp:AdminCreateUser, AdminDeleteUser, AdminGetUser on the pool. (Add to infrastructure/aws/lambda.tf; the auth lambda already has its Cognito perms — mirror them.)
- Add
COGNITO_USER_POOL_ID (+ client id) to the users lambda environment.
4. Frontend
Acceptance criteria
- Admin creates a user → invite email is sent, a
branch.users row exists with a non-null cognito_sub.
- Invited user sets a password on first login and can immediately call other lambdas (authenticated).
- Public self-registration is no longer possible.
- DB insert failure rolls back the Cognito user (no orphans either direction).
- Duplicate email returns
409, not a 500.
Edge cases / notes
- Seed/legacy users (
Ashley, Renee, Nour in db_setup.sql) have null cognito_sub. They need either re-invite via the new flow or a one-time backfill — call out in the migration step.
- Decide invite email copy / branding for the Cognito invite template.
Out of scope
- Bulk invite / CSV import.
- Role management UI beyond
isAdmin.
Decision (locked)
Adopt admin-invite provisioning via Cognito
AdminCreateUser. There is no open self-signup — only an admin creates accounts. This keeps Cognito as the single identity source and eliminates the orphaned-row state described below.Background / the bug this fixes
authenticateRequest(shared/lambda-auth/src/authenticate.ts:50) looks users up inbranch.usersbycognito_suband returnsisAuthenticated:falsewhen there is no match.POST /register(auth lambda) creates a Cognito user and abranch.usersrow withcognito_sub. Works — but is open signup, which we don't want.POST /users(users/handler.ts:221) inserts abranch.usersrow with no Cognito account and nullcognito_sub→ that user can never authenticate.Key fact that makes this clean:
AdminCreateUserreturns the new user'ssubinUser.Attributes. So we can storecognito_subat creation time — no post-confirmation trigger and no email-linking step required.Implementation
1. Backend —
userslambda:POST /users(admin only)ADMINauthorization gate.email,name,isAdmin).AdminCreateUser:DesiredDeliveryMediums: ['EMAIL'](sends the invite email + temporary password).UserAttributes:email,email_verified: true,name.TemporaryPassword).subfrom theAdminCreateUserresponseUser.Attributes.branch.usersrow withcognito_sub= thatsub, plusemail,name,is_admin.authregister flow): if the DB insert fails, callAdminDeleteUserto keep Cognito and the DB in sync.AdminCreateUserthrowsUsernameExistsException, return409.2. Backend —
authlambdaPOST /register(no public signup).POST /loginmust handle theNEW_PASSWORD_REQUIREDchallenge — invited users are created inFORCE_CHANGE_PASSWORDstate and must set a password on first login. Implement theRespondToAuthChallenge(NEW_PASSWORD_REQUIRED) exchange, returning the challenge to the client and accepting the new password.3. Infra —
infrastructure/aws/cognito.tfadmin_create_user_configblock withallow_admin_create_user_only = true(disables public sign-up) and an invite email template (invite_message_template).userslambda execution role needscognito-idp:AdminCreateUser,AdminDeleteUser,AdminGetUseron the pool. (Add toinfrastructure/aws/lambda.tf; the auth lambda already has its Cognito perms — mirror them.)COGNITO_USER_POOL_ID(+ client id) to theuserslambda environment.4. Frontend
POST /users(replaces theaccountsmock once [Frontend] Replace mock data with real API calls (donors, donations, accounts) #248 lands).NEW_PASSWORD_REQUIRED.Acceptance criteria
branch.usersrow exists with a non-nullcognito_sub.409, not a 500.Edge cases / notes
Ashley,Renee,Nourindb_setup.sql) have nullcognito_sub. They need either re-invite via the new flow or a one-time backfill — call out in the migration step.Out of scope
isAdmin.