Repository navigation
fix(authz): close pending-state authority bypass surfaces - #1250
Conversation
Closes Review C1: a never-authenticated account (emailVerified IS NULL)
could be auto-accepted into a drive when an admin selected them via
/users/search and POSTed { userId } to the invite route.
- /api/users/search now filters isNotNull(users.emailVerified) on both
the profile-search join and the email-exact-match query, with a
client-side defense-in-depth drop. Temp users from pending magic-link
invites no longer surface in search results.
- handleUserIdPath looks up the target's verification status before any
membership write. Suspended → 403; missing → 404; unverified → routes
through handleEmailPath (issues fresh magic-link, writes acceptedAt:
null, sends invitation email). Verified path is unchanged.
- driveInviteRepository gains findUserVerificationStatusById.
Adversarial test: "create temp user via email invite to drive A → revoke
that invite → admin uses /users/search to re-invite by userId on drive B"
no longer auto-accepts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Review C2: pending invitees with role=ADMIN (acceptedAt: null) could write into a drive they had not joined, read its full page tree, or receive its ownership transfer. Plus four lib-level call sites the original API-only regression scan could not see. Routes gated on isNotNull(driveMembers.acceptedAt): - pages/bulk-move (HIGH: pending admin → cross-drive write) - pages/bulk-copy (HIGH: pending admin → cross-drive write) - pages/tree (HIGH: pending member → page tree leak) - account/handle-drive (borderline-CRIT: ownership transfer to pending admin) Lib-level call sites gated: - lib/memory/discovery-service.ts (two call sites) - lib/ai/tools/drive-tools.ts (memberDrives lookup) - lib/ai/tools/channel-tools.ts (inbox-fanout member set) - lib/ai/tools/activity-tools.ts (accessible-drive enumeration) Gate-coverage test now scans apps/web/src/lib/** in addition to apps/web/src/app/api/**, with a single repository-seam allow-list entry that documents why findActivePendingMemberByEmail intentionally inverts the gate. Three previously-allow-listed route exemptions removed. Adversarial tests added to bulk-move, bulk-copy, pages/tree, account/handle-drive — each pins isNotNull(driveMembers.acceptedAt) on the WHERE clause via the operators-mock spy, so a future drift back to the role-only predicate trips the regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Review H1: createMagicLinkToken ran a blind type-wide delete on every call, silently invalidating prior unused magic-link tokens for the user. That broke three real flows: - 7-day pending invitation token → nuked when user requested 5-min sign-in - drive-A invitation → nuked when invitation issued for drive-B same email - Resend on same drive → original email's link dies first time admin clicks Fix: stop the pre-insert deletion entirely. Tokens have a TTL via expiresAt and a unique tokenHash; verifyMagicLinkToken refuses expired/used rows. Stale unused tokens age out naturally. Adversarial test (integration): two concurrent invitations for the same user — both magic links remain clickable and verifyMagicLinkToken succeeds for either, in either order. The previously-misnamed "cleans up old unused tokens for same user" assertion is inverted — same setup, preserved tokens. Adversarial test (unit): pin that db.delete is no longer called from createMagicLinkToken, so a future regression that re-introduces the blind type-wide cleanup trips immediately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Warning Rate limit exceeded
To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (23)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Self-review against rubric AppendixPer
|
| Axis | Score | Note |
|---|---|---|
| Contract stability | 2 | New tests are predicate-shape ("WHERE composes isNotNull(users.emailVerified)") and outcome-based ("temp user not in results"). A future refactor that keeps the gate intact will pass. |
| Mock quality | 1 | The file's existing setup uses ORM chain mocks (pre-existing convention). My adds extend that convention but also add isNotNull to the operators mock so the test asserts the gate via the spy, not the chain. Score 1 because I did not refactor the chain mocks themselves. |
| Assertion meaning | 2 | New tests assert (a) the operator spy was called with the right column and (b) the response body filters the row. Both observable. |
| Flake risk | 2 | No timers, no async randomness. |
| Spec clarity | 2 | Adversarial test names cite "Review C1 / temp-user-via-search re-invite path" inline. |
apps/web/src/app/api/drives/[driveId]/members/invite/__tests__/route.test.ts
| Axis | Score | Note |
|---|---|---|
| Contract stability | 2 | New tests added to the userId payload describe block; assert outcomes ("status 200, kind: invited", "createAcceptedMemberWithPermissions not called"), not call ladders. |
| Mock quality | 2 | Mocks the driveInviteRepository seam — added findUserVerificationStatusById to the existing mock surface. No ORM chains added. |
| Assertion meaning | 2 | Assertions verify the response body, the magic-link/email side effects, and the negative case (auto-accept did NOT happen). |
| Flake risk | 2 | No timers, deterministic. |
| Spec clarity | 2 | Three named adversarial paths: temp-user-via-userId, suspended-via-userId, missing-user. |
apps/web/src/app/api/__tests__/drive-member-gate-coverage.test.ts
| Axis | Score | Note |
|---|---|---|
| Contract stability | 2 | The lib sweep is intentionally regex-based on file content; refactor-resistant. |
| Mock quality | 2 | No mocks — pure file scan. |
| Assertion meaning | 2 | Asserts the violations list is empty, with a printed list of offending paths on failure. |
| Flake risk | 2 | Pure FS read. |
| Spec clarity | 2 | Each allow-list entry now references either follow-up #4 or the canonical-seam justification. |
apps/web/src/app/api/pages/{bulk-move,bulk-copy,tree}/__tests__/route.test.ts and account/handle-drive/__tests__/route.test.ts
| Axis | Score | Note |
|---|---|---|
| Contract stability | 2 | Each adversarial test pins isNotNull(driveMembers.acceptedAt) via spy + asserts 403 / 400 outcome. Refactor that keeps the gate will pass. |
| Mock quality | 1 | These files already used ORM chain / db.query.X.findFirst mocks — pre-existing convention. I added isNotNull to the operators mock and acceptedAt to the schema mock; did not refactor the chains. |
| Assertion meaning | 2 | Outcome (status code) + predicate-shape (operator spy). |
| Flake risk | 2 | Deterministic. |
| Spec clarity | 2 | Each test name cites the specific Review-C2 exploit path. |
packages/lib/src/auth/__tests__/magic-link-service.test.ts
| Axis | Score | Note |
|---|---|---|
| Contract stability | 2 | New unit test asserts db.delete not called — exactly the contract the fix establishes. |
| Mock quality | 1 | Existing chain mocks (pre-existing convention for this file). My adds extend without growing the chain surface. |
| Assertion meaning | 2 | Asserts db.delete mock not called — observable, not procedural. |
| Flake risk | 2 | Pure unit test. |
| Spec clarity | 2 | Cites Review H1 and the three adversarial flows that the fix unblocks. |
packages/lib/src/auth/magic-link-service.test.ts (integration)
| Axis | Score | Note |
|---|---|---|
| Contract stability | 2 | The "preserve unused tokens" test exercises the multi-token scenario end-to-end. |
| Mock quality | 2 | Real DB; no mocks at all. |
| Assertion meaning | 2 | Asserts both tokens persist and both verify successfully. |
| Flake risk | 2 | No timers; the only async ops are awaited DB writes. |
| Spec clarity | 2 | Test name + comment cite Review H1 and the three downstream flows. |
Aggregate
- 29 of 30 axis-scores at 2; one slice axis at 1 (mock quality, where I extended pre-existing chain conventions instead of refactoring them).
- All adversarial tests cite the Review finding and the named exploit path.
- §8 (security/adversarial coverage) explicitly satisfied — every closed gap has a paired adversarial test.
Refactoring the existing chain-mock conventions across the four touched route tests is out of scope for this PR (would balloon diff well past the 800-LOC ceiling and revisit Epic 1 design). I'd rather track that as a separate follow-up if the rubric reviewers decide it's worth doing now vs. with the next route-test author who needs to mock new query shapes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e5ffd1379b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codex P2: when the userId-path reroutes to handleEmailPath for an
unverified target, the prior code hardcoded permissions: [] which
silently dropped any caller-supplied page permissions while returning
kind: invited. Admins could believe permissions applied when they
were lost.
Forward the original permissions array. The email path's existing
422 check ("Page-level permissions cannot be granted to a user who
has not joined yet. Invite first, then grant permissions after they
accept.") fires automatically — no new logic needed.
Adversarial test pins the silently-dropped-permissions path: 422
returned, no token created, no member row inserted, no email sent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the route-level allow-list checks for the lib sweep: - stale entries: each LIB_ACCEPTED_AT_GATE_EXEMPT key must still reference driveMembers (otherwise remove the entry) - justification non-empty: every reason >= 10 chars Prevents the lib allow-list from rotting or carrying empty reasons. Currently only the repository seam is allow-listed; this guards future additions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(authz): emailVerified gate on userId path
Closes Review C1: a never-authenticated account (emailVerified IS NULL)
could be auto-accepted into a drive when an admin selected them via
/users/search and POSTed { userId } to the invite route.
- /api/users/search now filters isNotNull(users.emailVerified) on both
the profile-search join and the email-exact-match query, with a
client-side defense-in-depth drop. Temp users from pending magic-link
invites no longer surface in search results.
- handleUserIdPath looks up the target's verification status before any
membership write. Suspended → 403; missing → 404; unverified → routes
through handleEmailPath (issues fresh magic-link, writes acceptedAt:
null, sends invitation email). Verified path is unchanged.
- driveInviteRepository gains findUserVerificationStatusById.
Adversarial test: "create temp user via email invite to drive A → revoke
that invite → admin uses /users/search to re-invite by userId on drive B"
no longer auto-accepts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(authz): gate cross-drive routes on acceptedAt
Closes Review C2: pending invitees with role=ADMIN (acceptedAt: null)
could write into a drive they had not joined, read its full page tree,
or receive its ownership transfer. Plus four lib-level call sites the
original API-only regression scan could not see.
Routes gated on isNotNull(driveMembers.acceptedAt):
- pages/bulk-move (HIGH: pending admin → cross-drive write)
- pages/bulk-copy (HIGH: pending admin → cross-drive write)
- pages/tree (HIGH: pending member → page tree leak)
- account/handle-drive (borderline-CRIT: ownership transfer to pending admin)
Lib-level call sites gated:
- lib/memory/discovery-service.ts (two call sites)
- lib/ai/tools/drive-tools.ts (memberDrives lookup)
- lib/ai/tools/channel-tools.ts (inbox-fanout member set)
- lib/ai/tools/activity-tools.ts (accessible-drive enumeration)
Gate-coverage test now scans apps/web/src/lib/** in addition to
apps/web/src/app/api/**, with a single repository-seam allow-list entry
that documents why findActivePendingMemberByEmail intentionally inverts
the gate. Three previously-allow-listed route exemptions removed.
Adversarial tests added to bulk-move, bulk-copy, pages/tree,
account/handle-drive — each pins isNotNull(driveMembers.acceptedAt) on
the WHERE clause via the operators-mock spy, so a future drift back to
the role-only predicate trips the regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auth): preserve unused tokens on re-issue
Closes Review H1: createMagicLinkToken ran a blind type-wide
delete on every call, silently invalidating prior unused magic-link
tokens for the user. That broke three real flows:
- 7-day pending invitation token → nuked when user requested 5-min sign-in
- drive-A invitation → nuked when invitation issued for drive-B same email
- Resend on same drive → original email's link dies first time admin clicks
Fix: stop the pre-insert deletion entirely. Tokens have a TTL via
expiresAt and a unique tokenHash; verifyMagicLinkToken refuses
expired/used rows. Stale unused tokens age out naturally.
Adversarial test (integration): two concurrent invitations for the same
user — both magic links remain clickable and verifyMagicLinkToken
succeeds for either, in either order. The previously-misnamed "cleans
up old unused tokens for same user" assertion is inverted — same setup,
preserved tokens.
Adversarial test (unit): pin that db.delete is no longer called from
createMagicLinkToken, so a future regression that re-introduces the
blind type-wide cleanup trips immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(lint): drop unused eq import in users/search test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(authz): forward permissions on unverified userId reroute
Codex P2: when the userId-path reroutes to handleEmailPath for an
unverified target, the prior code hardcoded permissions: [] which
silently dropped any caller-supplied page permissions while returning
kind: invited. Admins could believe permissions applied when they
were lost.
Forward the original permissions array. The email path's existing
422 check ("Page-level permissions cannot be granted to a user who
has not joined yet. Invite first, then grant permissions after they
accept.") fires automatically — no new logic needed.
Adversarial test pins the silently-dropped-permissions path: 422
returned, no token created, no member row inserted, no email sent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(authz): parity guards on lib gate-coverage allow-list
Mirror the route-level allow-list checks for the lib sweep:
- stale entries: each LIB_ACCEPTED_AT_GATE_EXEMPT key must still
reference driveMembers (otherwise remove the entry)
- justification non-empty: every reason >= 10 chars
Prevents the lib allow-list from rotting or carrying empty reasons.
Currently only the repository seam is allow-listed; this guards
future additions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the three corroborated criticals tier findings from the 4-agent review battery on the drive invite-by-email epic (PRs #1233, #1234, #1236, #1239, #1243, #1245). All three findings share the same mental model — a pending or never-authenticated identity must not exercise authority — so they ship in one bundled PR.
Slice 1.1 — Close userId-path emailVerified bypass
Review C1 (
/tmp/review-1-end-to-end-report.mdfinding 3 +/tmp/review-2-zero-trust-report.md§1):apps/web/src/app/api/users/search/route.ts:63returned temp users (created by a prior pending magic-link invite) on exact-email match. An admin could pick the temp user and the userId branch of/api/drives/[driveId]/members/invite/route.ts:118-126,161-228calledcreateAcceptedMemberWithPermissions— admitting a never-authenticated account.Fix:
users/searchnow filtersisNotNull(users.emailVerified)on both the profile-search join and the email-exact-match query, with a JS-side defense-in-depth drop.handleUserIdPathlooks up target verification status before any membership write. Suspended → 403, missing → 404, unverified → routes throughhandleEmailPath(issues fresh magic-link, writesacceptedAt: null, sends invitation email). Verified path unchanged.findUserVerificationStatusById.Slice 1.2 — Close gate-coverage allow-list escalations
Review C2 (
/tmp/review-2-zero-trust-report.md§1, §2 Invariant 1):apps/web/src/app/api/pages/bulk-move/route.ts:64-71and/bulk-copy/route.ts:65-72(HIGH) — pending ADMIN could write into target driveapps/web/src/app/api/pages/tree/route.ts:55-62(HIGH) — pending member could read full page treeapps/web/src/app/api/account/handle-drive/route.ts:56-62(borderline-CRIT) — drive ownership transfer to pending adminapps/web/src/lib/memory/discovery-service.ts:111-114, 161-164apps/web/src/lib/ai/tools/drive-tools.ts:69-72apps/web/src/lib/ai/tools/channel-tools.ts:194-197apps/web/src/lib/ai/tools/activity-tools.ts:357-362Fix: all eight read sites gated on
isNotNull(driveMembers.acceptedAt). Gate-coverage regression test extended to scanapps/web/src/lib/**. Three previously-allow-listed route exemptions removed; remaining allow-list entries point to follow-up #4. The repository seam carries a single allow-list entry that documents whyfindActivePendingMemberByEmailintentionally inverts the gate (it surfaces pending rows for the pending-list UI).Slice 1.3 — Magic-link token isolation
Review H1 (
/tmp/review-1-end-to-end-report.mdfinding 1):packages/lib/src/auth/magic-link-service.ts:160-168ran a blind type-wide delete of unused magic-link tokens before each insert. That broke three flows:Fix: stop the pre-insert deletion entirely. Tokens have a TTL via
expiresAtand a uniquetokenHash;verifyMagicLinkTokenrefuses expired/used rows. Stale unused tokens age out naturally.Adversarial tests
Each slice carries an adversarial test that cites the specific exploit path in the test name:
users/search: temp-user-via-search re-invite path (verified by spy onisNotNull(users.emailVerified))invite/route: temp-user-via-userId-path adversarial path (assertscreateAcceptedMemberWithPermissionsnot called for unverified target)bulk-move,bulk-copy,pages/tree,account/handle-drive: pending-admin-can-X path (each pinsisNotNull(driveMembers.acceptedAt)on the WHERE clause via the operators-mock spy, so a future drift back to the role-only predicate trips immediately)magic-link-service: concurrent-invitations-from-two-drives path (two tokens issued, both remain clickable, both verify)Out of scope (per follow-up plan)
This PR is one of five slated follow-ups. Not addressed here:
?inviteDriveIdappend) — Follow-up 4member_removedfan-out,Promise.allSettledResponse.ok check, ralph-loop cleanup — Follow-up 2The four
Followupallow-list entries that remain reference Follow-up #4 explicitly; the rest are closed.Test plan
pnpm lintgreenpnpm typecheckgreen (viapnpm build)master— not introduced by this PR🤖 Generated with Claude Code