feat(identity): admin "bind sign-in email" tool (Phase 2b) — fixes Ahmed#3722
Merged
Conversation
POST /api/admin/users/:userId/linked-emails creates a fresh WorkOS user for the given email and binds it under the existing user's identity as a non-primary credential. After this, the user can sign in with either email — the auth middleware id-swaps non-primary logins to the canonical workos_user_id so they see the same workspace. UI: "Add sign-in email" button on the admin /admin/people detail panel for users that have a workos_user_id. Trust model: admin asserts the email belongs to the person. The endpoint refuses if the email is already an existing AAO account (higher-risk consolidation path; tracked in issue #3719). Direct fix for Ahmed-class escalations: admin opens his person detail, clicks "Add sign-in email," enters gmail, done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| process.env.WORKOS_COOKIE_PASSWORD ??= 'test-cookie-password-at-least-32-chars-long'; | ||
| }); | ||
| import request from 'supertest'; | ||
| import { initializeDatabase, closeDatabase, getPool } from '../../src/db/client.js'; |
Security review caught three real improvements (no blockers): 1. Partial-failure leak: if local INSERT or mergeUsers throws after WorkOS createUser succeeded, the WorkOS user was orphaned and a retry would silently create a new one. Now best-effort deleteUser cleanup, with the WorkOS id only surfaced when cleanup itself fails. 2. Admin UI confirmation: the bind action grants permanent sign-in access. Strengthen the prompt to require typing the target user's existing primary email to confirm. The button now shows whose account it'll bind to (display name + email). Avoids the "panel-open-on-the-wrong-row" footgun. 3. Test coverage gaps: add a test for the WorkOS 422 path (existing account in WorkOS that's not in our local DB) and the partial-failure rollback path. Deferred to follow-up: add CREATE UNIQUE INDEX CONCURRENTLY users_email _lower_unique. WorkOS's own gate currently makes the local TOCTOU unexploitable end-to-end, but the local check shouldn't depend on that. Also fixed the onclick HTML-injection footgun by switching to data-* attributes + event listener instead of inline JSON.stringify. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
CI on Phase 2b PR #3722 caught two pre-existing issues: 1. main has two migrations numbered 459 — 459_addie_escalation_dedup_key (mine, from earlier work) and 459_create_type_reclassification_log (from #3567, merged independently). Rename the latter to 461 (Phase 1's 460_identities is taken). Migration uses CREATE TABLE / INDEX IF NOT EXISTS so it's idempotent on systems that already applied it as 459. 2. CodeQL flagged the email-validation regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ as polynomial (overlap between [^\s@]+ and the literal \. allows backtracking). Replace with a linear isPlausibleEmail helper. WorkOS validates real syntax upstream — this is just a shape check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…primary bindings The Phase 1 identity-layer test asserted "every user has a primary binding," which was correct in Phase 1 (singleton identities only). After Phase 2a's mergeUsers rewrite and Phase 2b's admin bind tool, bound users intentionally have is_primary=FALSE — so on CI when all tests run together, the global invariant query saw the bind-email test's freshly- bound non-primary user and fired a false negative. Loosen to two scoped invariants: - every user has exactly one binding - every identity has exactly one primary binding The partial unique index idx_identity_workos_users_one_primary already enforces the second at the DB level; keeping it as a test gives us a clear failure if the index is ever dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 1, 2026
Closed
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.
Summary
Phase 2b of the identity layer. Adds an admin endpoint and UI button that creates a fresh WorkOS user for a given email and binds it as a non-primary credential under an existing user's identity. Direct fix for the Ahmed-class escalation (lost sign-in via gmail after the old delete-the-secondary merge flow).
Builds on:
What changes
POST /api/admin/users/:userId/linked-emailsAdmin UI
Trust model
How this fixes Ahmed
Local DB: gmail's WorkOS user has zero app-state rows; the swap routes him to his canonical (kyber1.com) WorkOS user where all his data lives.
Test plan
Phase plan recap
Follow-ups filed: #3717 (audit forensics), #3718 (primary deletion DoS), #3719 (self-service merge UX redesign).
🤖 Generated with Claude Code