fix(auth): a sign-in email is proven, unique, and written in one place (ent#720) - #3085
Conversation
Abilityai/trinity-enterprise#720) Every sign-in path resolves the account by email alone, so whoever holds an address on a users row holds that identity. These are the residual doors after #711: 1. Mailbox proof to bind. POST /api/users/me/email/code sends a 6-digit code to the NEW address (3 per 10 min). PUT /api/users/me/email requires {email, code}. Codes carry a purpose (email_login_codes.purpose): `email_bind:<user id>` completes only that account's bind; a bind code never signs in and a sign-in code never binds. Both routes are interactive-only. The one no-proof bind is the #82 transition on an install that cannot deliver mail (provider `console`): an interactive admin, audited as email_bind_unverified. Anyone else there gets 409 email_verification_unavailable. The onboarding step and the Settings card gain the code step (auth store `bindOwnEmail`). 2. Unique. idx_users_email_unique ON users(lower(email)) WHERE email IS NOT NULL, on both tracks (SQLite `ent720_email_identity`, Alembic `0081_ent720_email_identity`). Existing duplicates are resolved first: blank becomes NULL, and per address the earliest-created account keeps it while the rest become NULL, logged by username only. 3. One writer. create_user, update_user, the password upsert and email sign-in creation all write through `_insert_user` / `_update_user_row`. These refuse a held address (EmailInUseError → 409 email_in_use) and map a lost race on the index to the same refusal. A writer-census test enumerates them. get_user_by_email is case-insensitive. 4. A reclaimed username is not a 500. Email sign-in whose `username = email` is taken creates a suffixed username. Auth0 sign-in resolves the HOLDER of the address first and never hands it back to an account that re-bound away. 5. Redeemers honour suspension. Telegram and WhatsApp redemption, the MCP inline redeemer, email_has_agent_access and the per-message channel gate (open_access included) refuse a suspended account, through db.is_email_account_suspended. Second factor on the channel redeemers is out of scope: they cannot present a challenge. It is recorded in FR-4 as a follow-up. Tests: - test_ent720_email_binding (28 tests); - suspended cases in the Telegram, WhatsApp and router suites; - emailBindProof.spec.js (mounted). Mutations: bind-without-proof, no unique index, no username suffix, and suspension ignored each turn tests red. The related backend suites (67 files) pass on seeds 12345 and 99999 (1571 passed). Frontend: 181 files and 3728 tests pass, and the build is OK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…viction-proof tests The duplicate-email sweep read username/created_at unconditionally, which crashed the #1160 concurrent-boot fixture's minimal users table. Read those columns only when present. The unit tests now resolve EmailInUseError and UserOperations from the module the live db singleton uses (another test may re-import db.users), and stub alembic.op via monkeypatch.setitem so the sys.modules lint ratchet stays at zero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
obasilakis
left a comment
There was a problem hiding this comment.
This PR requires the following changes before merge:
-
Fix the red
regression diff: fourTestUniqueAndOneWritertests fail. The failing tests aretest_create_user_refuses_a_held_address_any_case,test_update_user_refuses_a_held_address,test_the_password_upsert_cannot_insert_a_duplicateandtest_a_lost_race_on_the_index_is_the_same_refusal. In each one,pytest.raisesmisses thedb.users.EmailInUseErrorthat the code raises._users_mod()resolves the class throughsys.modules[type(db._user_ops).__module__]. After an earlier test evicts and re-importsdb.users, that name points at the new module, but thedbsingleton still raises the old class. Reproduced locally with a probe test that importsdatabaseand then doesdel sys.modules["db.users"]; import db.users, which gives exactly these 4 failures. Reading the namespace the raising code actually uses makes them pass, both with the probe (29 pass) and alone (28 pass):def _users_mod(db): import types return types.SimpleNamespace(**type(db._user_ops)._insert_user.__globals__)
-
Cap wrong guesses on the bind code.
PUT /api/users/me/emailchecks the code withverify_login_codeand never counts failures. Email sign-in (routers/auth.py) and the portal (client_portal/router.py) both stop a code afterOTP_MAX_ATTEMPTS = 5wrong tries. Here, any signed-in human can request up to 3 live codes per 10 minutes to an unclaimed address and then guess without limit. A guess matches any of the live codes. This reopens the "bind an unclaimed address" door that this PR closes. Suggested fix: reusecheck_otp_rate_limit/record_otp_attemptunder a bind-scoped key such asotp_attempts:bind:{user_id}:{email}. Use the bind-scoped key, not the bare email: a bare-email key would let wrong bind guesses lock the address owner out of platform sign-in, the cross-surface lockout the portal key prefix avoids. Please add a test that 5 wrong codes lock out the 6th attempt, including the right code.
Non-blocking:
- The Alembic revision
0081is one of six open PRs that parent onto0080_agent_skill_sets(#3076, #3036/#3035, #3022/#3021, #2984). Whichever lands second re-parents onto the first. None of the others touchusersoremail_login_codes, so the re-parent is mechanical. - The migration clears the address on every duplicate account except the earliest-created one. The next email sign-in for that address then goes to the earliest account, and the cleared account's owner loses email access to it. Please state this in the PR description so operators know to check the migration log.
Refs abilityai/trinity-enterprise#720carries no closing keyword. The issue lives in the enterprise tracker, so it needs a manualstatus-in-devafter merge either way.- There is no feature-flow doc for
POST /me/email/codeor the bind flow.
The rest checks out: both migration tracks share one pinned decision function, every users.email write goes through a single checked writer with an AST census, a lost race on the unique index maps to 409, a bind code cannot be used as a sign-in code, agent and MCP keys are refused, and the tests run against a real schema and the real routes.
Please address these items and request re-review.
|
✅ Alembic head check clear — merging this PR into Previously flagged; resolved. Advisory — this check does not block merge. · head_sha: |
…est helper
PUT /api/users/me/email verified the bind code without counting failures,
so a caller could mint 3 live codes per window and guess without limit.
Reuse the sign-in OTP limiter (OTP_MAX_ATTEMPTS=5 per 10 min) under a
bind-scoped key `otp_attempts:bind:{user_id}:{email}` — never the bare
address, which would let wrong bind guesses lock the owner out of email
sign-in. Past the cap even the right code is refused (429
too_many_attempts); a successful bind clears the counter, as sign-in does.
_users_mod now reads the raising function's own globals, so the tests hold
after another suite evicts and re-imports db.users.
Refs Abilityai/trinity-enterprise#720
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on to 0082 dev added 0081_portal_messages_unread_idx (#3076) off the same parent. Rename the Alembic revision to 0082_ent720_email_identity, parented on 0081_portal_messages_unread_idx, and order the SQLite entry after dev's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sibling suite (test_telegram_login_gate) parks a bare `routers.auth` stub in sys.modules at collection time. The bind route now reaches the OTP limiter through that module, so the `api` fixture re-imports the real one when a stub holds the slot and always backs its counter with an in-memory Redis. Refs Abilityai/trinity-enterprise#720 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/users/me/email/code and PUT /api/users/me/email: code purpose, console-provider admin bypass, the bind-scoped attempt cap, refusal codes, and what the duplicate-resolving migration does. Refs Abilityai/trinity-enterprise#720 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@obasilakis thanks. Item by item: 1. Red 2. Cap wrong guesses on the bind code: fixed in 4873c43, and the tests were hardened in 05e16d5.
Mutation:
05e16d5 is needed because Non-blocking
Tests: I ran the ent720 file, the channel gates, 🤖 Generated with Claude Code |
|
Resolve by running |
|
Resolve by merging |
|
merge-train: not on this train — rides the next one once fixed. The review items from 13:15 are addressed (cap +
|
…sion to 0083 dev gained 0082_agent_sync_state_divergence (#3035), forking the Alembic head. Chain 0083_ent720_email_identity off it and order the SQLite entry after agent_sync_state_divergence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ent#720 requires the mailed code for PUT /me/email, except an admin on a console-provider install. #2996's session-path test binds without a code, so it now states that exception explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
merge-train: not on this train. It rides the next one once fixed. The earlier review items are verified fixed at Needs your call:
Mechanical items, needed before merge:
Lower priority:
Not your fault: the journey-smoke failure is the 09-29/30 infra break that #3107 fixes. obasilakis's CHANGES_REQUESTED review still stands, so the PR needs a re-review after the push. |
…il_identity (#3085) dev gained 0083_execution_conversation_key (#3110) off the same parent as this PR's 0083_ent720_email_identity — a two-head fork (#2068). The two touch disjoint tables, so the unmerged revision is re-parented: renamed to 0084_ent720_email_identity with down_revision 0083_execution_conversation_key. SQLite list keeps both entries, dev's first. Test path + down_revision pin, migrations.py docstring and the feature flow follow the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
schema.py and both migration tracks create the index; tables.py MetaData did not, so #746 autogenerate would propose dropping it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ance (#3085) count_recent_code_requests had no purpose filter, so sign-in (routers/auth.py) and the MCP code request counted bind rows: any signed-in user could request bind codes for someone else's address three times per 10 minutes and keep the owner's sign-in codes suppressed while flooding their inbox. - Sign-in counters count sign-in codes only (purpose IS NULL), the same exact-match rule verify_login_code applies. - The bind route limits its CALLER across every address (count_recent_codes_for_purpose('email_bind:<id>')), 3 per 10 minutes, so no account can spend another's allowance. Tests drive the real route: another account's bind requests leave the sign-in counter at 0; the cap is per caller across addresses; one account cannot spend another's; sign-in codes do not spend a bind allowance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
merge-train: this PR is on today's train with #3122, #3119 and #3120. I pushed three commits to your branch; each one covers a single change:
I ran the ent720 file plus the related suites (186, 2381, 2996 ×2, admin email login, ent311, OTP rate limiting, schema parity, 1160, verification email, sharing null email) on seeds 12345 and 99999: 228 passed, with no new failures against The PR body now says Two things still to come:
|
…5_ent720_email_identity (#3085) #3120 landed 0084_agent_loops_chain_depth off the same parent as this PR's 0084_ent720_email_identity. Disjoint tables, so the revision is re-parented: 0085_ent720_email_identity <- 0084_agent_loops_chain_depth. SQLite list keeps both entries, dev's first — the same resolution train #3126 was gated on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
merge-train: all items addressed (_users_mod fix + bind-guess cap by the author; re-parent, index, counter by the train). Re-review welcome post-merge.
Summary
Every sign-in path resolves an account by email alone, so whoever holds an address on a
usersrow holds that identity. This closes the remaining ways an address could be taken or duplicated:PUT /api/users/me/emailnow requires a 6-digit code sent to the new address (POST /api/users/me/email/code). The code has its own purpose (email_bind:<user id>) and can never be redeemed as a sign-in code.consoleemail provider, only an admin can bind without a code, and the address is recorded as unverified.409 email_verification_unavailableat both steps.429 too_many_attempts. The counter is bind-scoped (otp_attempts:bind:{user_id}:{email}), so wrong bind guesses never lock the address's owner out of email sign-in. A successful bind clears it.lower(email), covering rows whereemail IS NOT NULL. A lost race on the index surfaces asEmailInUseError(409email_in_use), never a 500.create_user,update_userand the password upsert all go through the same duplicate check.username == <old email>, the next email sign-in for that address creates a suffixed account instead of a 500. The account lookup checks the address's holder first.Migration (both tracks)
SQLite
ent720_email_identity+ Alembic0084_ent720_email_identity(down_revision0083_execution_conversation_key, single head; renumbered after #3076 and #3110 landed ondev):email_login_codes.purpose;The log names affected accounts by username only, never by address. Both tracks share one decision function, and a test pins the two copies identical.
Docs
docs/memory/feature-flows/email-authentication.md— new "Binding a Sign-In Email" section: both routes, code purpose, the console-provider admin bypass, the attempt cap, and every refusal code.Test plan
tests/unit/test_ent720_email_binding.py(32 tests, incl. the guess cap: 5 wrong → the right code is refused; sign-in counter untouched; per-account counters) + channel gate suites, onpytest-randomlyseeds 12345 and 99999. Mutation: removing the cap check, or keying it on the bare address, turns the cap tests red. The tests use a real SQLite database built byinit_schemaand the real/api/usersroutes.test_1160_migration_atomicity,test_lint_sys_modules(a reduceduserstable shape is tolerated; no newsys.modulesviolations).emailBindProof.spec.js(mounted).verify-local: the image builds,import mainworks, the stack boots and is healthy, and the integration stage passes.Fixes abilityai/trinity-enterprise#720
🤖 Generated with Claude Code