Authentication

Admin Detection

How admin status is determined and enforced across every layer of the BizTech stack.


The Rule

A user is an admin if their email domain is ubcbiztech.com. There are no database role columns, no Cognito groups, and no RBAC tables. The admin check is purely email-based and runs independently in four places.


1. User Creation (services/users/handler.js)

When a user record is created via POST /users/, the handler auto-sets the admin field:

const email = data.email.toLowerCase()
const isBiztechAdmin =
  email.substring(email.indexOf('@') + 1, email.length) === 'ubcbiztech.com'

This sets admin: true on the biztechUsers record at creation time. The admin field is listed in IMMUTABLE_USER_PROPS (defined in constants/tables.js), which means it cannot be changed via PATCH /users/{email} — any attempt to include admin in a PATCH body causes the handler to throw an error.

The same logic runs during the Stripe membership webhook in services/payments/handler.js when creating a user via OAuthMemberSignup() or userMemberSignup().


2. Frontend Middleware (src/middleware.ts)

The middleware fetches the user record via GET /users/self (which returns the admin field from DynamoDB). If the user is not an admin and the path starts with /admin:

if (pathname.startsWith('/admin') && !userProfile.admin) {
  return NextResponse.redirect(new URL('/', req.url))
}

This is a server-side check that runs on every non-allowlisted request.


3. Frontend Query Hook (src/queries/user.ts)

The getUserAttributes() function checks the email domain directly from Cognito attributes:

const isAdmin = email.split('@')[1] === 'ubcbiztech.com'

This is used by React components to conditionally render admin UI elements (event dashboard controls, member management, email tools). The useUserAttributes() React Query hook wraps this with a 20-minute stale time.

Two Admin Checks on the Frontend

The middleware reads admin from the biztechUsers database record. The query hook checks the email domain from Cognito. These should always agree, but they are independent checks.


4. Backend Service Handlers

Each admin-only service checks the email from the decoded JWT claims:

const email = event.requestContext?.authorizer?.claims?.email
if (!email?.endsWith('@ubcbiztech.com')) {
  throw helpers.createResponse(403, { message: 'Unauthorized' })
}

Admin-Only Services

ServiceWhat's Protected
membersAll endpoints (create, get, getAll, update, delete, grant)
usersGET /users/ (list all users)
emailsAll endpoints
prizesAll CRUD endpoints
btxAdmin-specific endpoints (project management, account resets)

Non-Admin Cognito Endpoints

Some endpoints require Cognito authentication but are not admin-only. These endpoints use the JWT email to scope access to the caller's own data:

ServiceEndpointBehavior
usersGET /users/{email}Non-admin gets own record regardless of path param
usersPATCH /users/{email}Updates own record
profilesGET /profiles/user/Gets own profile
profilesPATCH /profiles/user/Updates own profile

Why Admin Cannot Be Changed via API

The IMMUTABLE_USER_PROPS constant in constants/tables.js contains ["admin"]. The users update handler (services/users/handler.js) checks for any field in this array in the PATCH body and throws an error if any are present. This means:

  • Admin status is set once at user creation
  • It cannot be modified through the API
  • To change it, you must update the biztechUsers DynamoDB record directly

The admin Field vs. Admin Paths

The membership page (src/pages/membership.tsx) has a separate admin detection for the payment bypass:

if (email.toLowerCase().endsWith('@ubcbiztech.com')) {
  // Skip Stripe payment — directly create member records via API
}

Admins (BizTech exec team members) do not pay for membership. The membership page detects the admin email and directly calls the member/user/profile creation endpoints instead of redirecting to Stripe.


Previous
Implementation