N.E.S.T. Access Gate (Cognito `nest-access` group)

Post-migration, N.E.S.T. is reachable only by Cognito users who are members of the nest-access group. This page documents the gate’s mechanism, how to add a user, the per-environment pool IDs, and what the SPA must do to read the claim.

TL;DR. A Cognito group named nest-access exists on all three Phenom user pools (phenom-dev-local, phenom-staging, phenom-prod). Three independent layers enforce or surface membership: (1) the N.E.S.T. SPA refuses sign-in for non-members, (2) the nest-api Worker rejects any /api/* request from a non-member’s JWT – the real security boundary, and (3) the hasura-cognito-sync-users Lambda mirrors membership into a users.nest_access boolean column on each sign-in so Hasura queries can filter without re-checking Cognito. The group itself stays a pure label – no IAM role, no JWT claim manipulation.

Why this exists

The Phenom Cognito user pools hold every platform user – mobile-app sign-ups, drop visitors, etc. N.E.S.T. (the operator-facing dashboard) is not for end-users; only staff/operators/approved insiders should reach it. Before this gate, that filtering happened entirely at the application layer (or at the CF Access edge for the legacy nest-firebase.thephenom.app surface). Post-Cognito-migration, the SPA needs a Cognito-native signal it can read from the ID token without an extra round-trip – cognito:groups is that signal.

Per-environment pool IDs

Env Pool name Pool ID
Dev phenom-dev-local us-east-1_AkG9mnbjA
Staging phenom-staging us-east-1_n8gO6SbP6
Prod phenom-prod us-east-1_knEL7cqS3

All three pools have the nest-access group declared in phenom-infra Terraform. The group ships empty in every pool.

Three enforcement layers (live as of 2026-05-23)

The gate is enforced at three independent layers. Each does a different job; together they cover the surface.

1. SPA – UX bounce at sign-in (phenom-backend admin_sandbox/phenom)

The Zustand auth store’s login(), restoreSession(), and completeNewPassword() handlers decode the ID token’s cognito:groups claim and reject the sign-in if nest-access is missing. Implementation lives in src/stores/authStore.ts:

// Exports
export const NEST_ACCESS_GROUP = 'nest-access'
export class NoNestAccessError extends Error { ... }

// Inside login() / restoreSession() / completeNewPassword():
const payload = session.getIdToken().decodePayload()
if (!hasNestAccess(payload)) {
  cognitoSignOut()                                 // purge Cognito storage
  set({ isLoading: false })
  throw new NoNestAccessError(payload['email'])   // typed error for the form
}

The sign-in form (src/features/auth/sign-in/components/user-auth-form.tsx) catches NoNestAccessError and renders a clear “Your account is not authorized to access N.E.S.T. Contact an administrator to request access.” instead of a generic “Authentication failed”, so users don’t loop on their password.

Important caveats:

  1. The claim is absent – not an empty array – when the user has no groups. Normalize with ?? [] before .includes(…). Cognito omits the claim entirely for ungrouped users.
  2. The claim is on the ID token, not the access token. The two are issued together at sign-in.
  3. This layer is bypassable – a caller who reads the raw JWT out of localStorage can hit /api/* directly. That’s why layer 2 exists.

2. Worker – real security boundary (phenom-backend admin_sandbox/nest-api)

The nest-api Cloudflare Worker’s JWT validation middleware checks the same claim after signature/issuer verification, BEFORE proxying to Hasura. Implementation in src/auth.ts:

export const NEST_ACCESS_GROUP = 'nest-access'

// In verifyCognitoJwt() return value, alongside email/sub:
const groups: string[] = Array.isArray(payload['cognito:groups'])
  ? payload['cognito:groups']
  : []

// In authenticate():
const verified = await verifyCognitoJwt(token, env)
if (!verified) return null
if (!isInNestAccessGroup(verified.groups)) {
  console.log(`nest-api: rejecting valid Cognito token for ${verified.email} -- not in ${NEST_ACCESS_GROUP}`)
  return null
}

A non-member’s request gets a 401 from /api/* even if their JWT is otherwise valid. This is the non-bypassable boundary – independent of the SPA’s check.

3. Lambda – Hasura mirror (phenom-infra hasura-cognito-sync-users)

The hasura-cognito-sync-users Lambda (post_authentication + post_confirmation triggers on all three pools) calls AdminListGroupsForUser after the user signs in and upserts a nest_access boolean field into the Hasura users row alongside the other Cognito attributes:

// In the handler, after building the standard payload:
if (allowedColumns.has('nest_access') && event.userPoolId && event.userName) {
  const groups = await listUserGroups(event.userPoolId, event.userName)
  payload.nest_access = groups.includes(NEST_ACCESS_GROUP)
}

The Lambda’s IAM role has cognito-idp:AdminListGroupsForUser scoped to the env’s user pool ARNs. Graceful degradation: if the column doesn’t yet exist, GraphQL introspection drops it from the payload (no-op for the new field). If the Cognito API call fails, nest_access defaults to false for this run and resyncs on the next sign-in.

This layer is for query convenience, not enforcement. Hasura clients (the SPA, the action handler Lambda, future apps) can ask “show me all admins” via users(where: { nest_access: { _eq: true } }) without re-querying Cognito or asking the Worker. The source of truth remains the Cognito group; Hasura is a read-only mirror.

How to add a user to the group

Via AWS CLI (preferred – captured in audit logs)

# Dev
aws cognito-idp admin-add-user-to-group \
  --region us-east-1 \
  --user-pool-id us-east-1_AkG9mnbjA \
  --group-name nest-access \
  --username <user-email-or-sub>

# Staging
aws cognito-idp admin-add-user-to-group \
  --region us-east-1 \
  --user-pool-id us-east-1_n8gO6SbP6 \
  --group-name nest-access \
  --username <user-email-or-sub>

# Prod
aws cognito-idp admin-add-user-to-group \
  --region us-east-1 \
  --user-pool-id us-east-1_knEL7cqS3 \
  --group-name nest-access \
  --username <user-email-or-sub>

The new membership takes effect on the user’s next sign-in – JWTs are stateless, so existing tokens don’t auto-update. If you need it to take effect immediately, also call admin-user-global-sign-out to invalidate their current sessions and force a fresh sign-in.

Via AWS Cognito Console

  1. Open the AWS Console → Cognito → User pools → pick the env’s pool
  2. Users tab → find the user → Add user to group
  3. Pick nest-access → Add

The Console path produces no audit-log trail beyond CloudTrail; prefer the CLI when granting access to anyone other than yourself for the duration of a smoke test.

How to remove a user

aws cognito-idp admin-remove-user-from-group \
  --region us-east-1 \
  --user-pool-id <pool-id> \
  --group-name nest-access \
  --username <user-email-or-sub>
# Then optionally:
aws cognito-idp admin-user-global-sign-out \
  --region us-east-1 \
  --user-pool-id <pool-id> \
  --username <user-email-or-sub>

Removal blocks future sign-ins from gaining access; existing JWTs remain valid until expiry unless you global-sign-out.

What the group is NOT

  • Not a Hasura role. Cognito nest-access membership flows into Hasura only via the users.nest_access column written by hasura-cognito-sync-users – a data column for queryability, not a role for Hasura’s RBAC engine. (Separately, as of 2026-05-28 the Hasura admin role is granted by the nest-admin Cognito group via the pre-token-generation Lambda, decoupled from chat_members.role and fail-closed; see Teams, admin and infrastructure. The legacy chat_members.role lookup now serves chat tiering only.)
  • Not an IAM grant. The group has no role_arn. Membership doesn’t issue AWS-resource credentials.
  • Not a precedence-ordered tier. No precedence field. Group is a pure boolean: in or out.
  • Not a CF Access policy. Where CF Access is still in front of a N.E.S.T. surface (e.g. nest-firebase.thephenom.app), users must clear both gates. nest.thephenom.app (prod) currently has no CF Access in front – Cognito + the three layers above are the only auth.

Terraform definition

Three resources, one per pool, all named nest-access at the Cognito display-name layer:

# environments/development/main.tf
resource "aws_cognito_user_group" "nest_access" {
  name         = "nest-access"
  user_pool_id = aws_cognito_user_pool.main.id     # phenom-staging
  description  = "Gate group: members can access the N.E.S.T. dashboard."
}

resource "aws_cognito_user_group" "nest_access_local" {
  name         = "nest-access"
  user_pool_id = aws_cognito_user_pool.local.id    # phenom-dev-local
  description  = "Gate group: members can access the N.E.S.T. dashboard."
}

# environments/production/cognito.tf
resource "aws_cognito_user_group" "nest_access" {
  name         = "nest-access"
  user_pool_id = aws_cognito_user_pool.main.id     # phenom-prod
  description  = "Gate group: members can access the N.E.S.T. dashboard."
}

Membership is administered out-of-band; the group is intentionally empty at apply time.

Hasura schema dependency

Layer 3 (the Lambda mirror) writes to a nest_access column on the users table. The column must be added via a Hasura migration in phenom-backend/hasura/migrations – without it, the Lambda silently no-ops on the new field. Recommended schema:

ALTER TABLE users
  ADD COLUMN nest_access boolean NOT NULL DEFAULT false;

Once the column exists and the Lambda has been deployed, populate it for existing users by triggering a re-sync (any sign-in repopulates that user’s row).