Skip to content

Lock the reviewer's email address so revocation can't be bypassed - #3239

Merged
jeremy merged 3 commits into
mainfrom
saas-reviewer-email-lock
Oct 25, 2026
Merged

jeremy merged 3 commits into
mainfrom
saas-reviewer-email-lock

Conversation

@jeremy

@jeremy jeremy commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

This fixes a revocation bypass in the reviewer sign-in from #3227, which is merged but not lit. Land it before the light-up.

The bypass

The finding was raised by Codex on #3192: thread. With a normal session from the reviewer code, a code holder can request an email change to an address they control and confirm it. An email change doesn't rename an identity. User#change_email_address moves the user to another identity, found or created for the new address. The demo account's membership then belongs to the code holder's own identity, which signs in by emailed code like anyone else. saas:reviewer_sign_in:sign_out cleans the configured identity, which no longer holds anything, so the code holder keeps the account after rotation.

The fix: lock the address, don't chase it

  • User::ReviewerEmailAddressLock, prepended onto User in the SaaS engine. For the reviewer's users, send_email_address_change_confirmation sends nothing and change_email_address returns false. That second guard also refuses a confirmation link minted earlier, and Users::EmailAddresses::ConfirmationsController already answers false with its invalid-token response.
  • Users::ReviewerEmailAddressLock, included into Users::EmailAddressesController. It refuses new and create up front: a redirect with "This email address can't be changed", or 403 as JSON. The API path is refused too.
  • Why this obviates pinning to an id. The reviewer is a comped internal identity, so nobody needs to change its email. Once its users can't move, the configured email always finds the identity, and the identity always holds the membership. No code path writes Identity#email_address; the only one is this move. So recording the identity's id at generate time would add a config value and a production lookup, and protect against nothing that's still possible.

The same class, closed in the same pass

I asked what else could leave a code holder a way back in after rotation, beyond the email instance:

  • Pending session transfers. Identity::Transfer tokens live for 4 hours and mint a session. Sign-out now deletes them.
  • The account join code. Any member can read it (Account::JoinCodesController#show has no admin check), and redeeming it adds a user under an identity of the code holder's own. Sign-out now resets the join codes of the reviewer's accounts, so a code read during a review stops working at rotation.

Decided by Jeremy (2026-10-24): (a). A code holder who redeemed the join code during a review has their own user in the demo account, and rotation doesn't remove it. So the members are checked after each review: saas:reviewer_sign_in:sign_out now also prints every active member of the reviewer's accounts (Identity::ReviewerSignIn.members_of_reviewer_accounts, 65bb7f0). Deactivate anyone there who isn't the reviewer or the account's own staff.

  • (b) rejected: the reviewer keeps its role, because reviewers need Fizzy's full functionality.
  • (c) rejected: it would build machinery for a seeded demo account.

Rotation after each review: the operational steps live in the internal reviewer runbook, not here. Sign-out's member list is the step that catches anyone who joined during the review.

Tests

saas/test/models/user/reviewer_email_address_lock_test.rb holds the test the finding asked for:

  • The reviewer tries to move its user to an attacker address with a confirmation token.
  • That's refused, the user stays on the reviewer identity, and no identity is created for the attacker address.
  • Sign-out still ends the reviewer's sessions.
  • The code doesn't authenticate under the attacker address.

Other coverage:

  • No confirmation email is sent.
  • Other users can still change their address.
  • Nothing is locked when the feature is unconfigured.

saas/test/controllers/users/reviewer_email_address_lock_test.rb:

  • Requesting a change is refused, as HTML and as JSON, with no mail.
  • A minted confirmation link answers 422, and the user doesn't move.

saas/test/models/identity/reviewer_sign_in_test.rb:

  • Sign-out deletes transfers.
  • Sign-out resets the reviewer's accounts' join codes and no others.

Mutation checks:

  • With the two engine lines removed, the lock tests fail (4 failures, 1 error).
  • With the transfer and join-code lines removed, the sign-out tests fail (2 failures).

The existing email-change tests pass unchanged.

Review round on 65bb7f0, and where this control stops

Codex raised three findings on 65bb7f0:

  • Fixed in 357bd46: the app refuses to deactivate the reviewer's users, so the account can't drop out of sign-out's reach.
  • Fixed in 357bd46: the member audit prints escaped values.
  • Declined: a join-code redemption racing the reset. The window is milliseconds inside one server-side request, at a moment the client can't see. A general Account::JoinCode#redeem_if fix would belong on main.

These are the fourth and fifth ways back in that we've found, after the email change and the join code. Each new one fixes another path by which a code holder keeps a membership in the demo account. The protected thing is a seeded demo account with no customer data. The code reaches nothing else, because the identity belongs only to that account. The controls here are bounded to the paths that move or add a membership:

  • email change;
  • self- or peer-deactivation;
  • transfer tokens;
  • join codes, reset at rotation;
  • the after-review member list.

Accepted residual: a determined code holder may keep access to the demo account until someone notices them in the member list. A further finding of the same kind should be weighed against that residual, not patched by reflex.

…mbership out of revocation's reach

An email change moves a user to another identity. A holder of the reviewer code could move the reviewer's membership to an address they control, where rotating the code and saas:reviewer_sign_in:sign_out can't reach it. The reviewer's users now refuse an email change, both the request and a confirmation link minted earlier. Sign-out also deletes pending session transfers and resets the join codes of the reviewer's accounts, the other ways a code holder could keep a way back in.
@jeremy

jeremy commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T09:06:55.014219Z 357bd46 Manual request
🔒 Security Review ✅ Completed 2026-10-25T09:12:27.207314Z 357bd46 Manual request

Security findings

Finding details are still loading. Check the individual review comments.

ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: db0b2416c3

ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: db0b2416c3

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

A code holder who redeemed a join code during a review has a user of their own in the demo account, and rotation doesn't remove it. Jeremy chose to check the members after each review rather than restrict the reviewer's role or deactivate users automatically, so saas:reviewer_sign_in:sign_out now prints every active member of the reviewer's accounts for that check.
@jeremy

jeremy commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65bb7f0a4f

ℹ️ 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".

Comment thread saas/app/models/identity/reviewer_sign_in.rb
Comment thread saas/app/models/identity/reviewer_sign_in.rb
Comment thread saas/lib/tasks/reviewer_sign_in.rake Outdated
…escaped

Deactivating a user clears its identity, so a reviewer deactivated from the app would drop its account out of sign_out's join-code reset and member list. The app now refuses to deactivate the reviewer's users, whoever asks; operators use a console. The audit prints names and emails with inspect, so a member's display name can't inject newlines or terminal control sequences into the check.
@jeremy

jeremy commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 357bd4632a

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@jeremy jeremy changed the title HELD: Lock the reviewer's email address so revocation can't be bypassed Lock the reviewer's email address so revocation can't be bypassed Oct 25, 2026
@jeremy
jeremy marked this pull request as ready for review October 25, 2026 08:59
Copilot AI balanced review requested due to automatic review settings October 25, 2026 08:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive revocation retains an accepted dependency on manual membership auditing that warrants final human validation before enablement.

No open issues 🎉

What changed in this PR

This PR hardens the SaaS-only reviewer sign-in introduced in #3227 before enablement, keeping the reviewer’s membership reachable by the revocation task.

Changes:

  • Blocks reviewer email changes and deactivation through the app.
  • Extends sign-out to delete pending session transfers, reset account join codes, and print an escaped member list for manual review.
  • Adds model and controller tests for the restrictions and cleanup behavior.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
saas/​test/​models/​user/​reviewer_email_address_lock_test.rb Tests email locking and continued revocation reach.
saas/​test/​models/​identity/​reviewer_sign_in_test.rb Tests transfer cleanup, join-code rotation, and member listing.
saas/​test/​controllers/​users/​reviewer_membership_lock_test.rb Tests reviewer deactivation restrictions.
saas/​test/​controllers/​users/​reviewer_email_address_lock_test.rb Tests email-change request and confirmation rejection.
saas/​lib/​tasks/​reviewer_sign_in.rake Prints escaped member details for the post-review audit.
saas/​lib/​fizzy/​saas/​engine.rb Installs the SaaS reviewer guards.
saas/​app/​models/​user/​reviewer_email_address_lock.rb Prevents reviewer email confirmation delivery and identity moves.
saas/​app/​models/​identity/​reviewer_sign_in.rb Expands revocation cleanup and exposes member auditing.
saas/​app/​controllers/​concerns/​users/​reviewer_membership_lock.rb Rejects reviewer deactivation through the controller.
saas/​app/​controllers/​concerns/​users/​reviewer_email_address_lock.rb Rejects reviewer email-change requests in HTML and JSON.

Review effort: Balanced

@jeremy

jeremy commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 357bd4632a

ℹ️ 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".

@jeremy
jeremy merged commit cfc50c3 into main Oct 25, 2026
14 checks passed
@jeremy
jeremy deleted the saas-reviewer-email-lock branch October 25, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants