[Admin] Restrict sensitive email contents to superadmins - #14606
Draft
garyhtou wants to merge 8 commits into
Draft
[Admin] Restrict sensitive email contents to superadmins#14606garyhtou wants to merge 8 commits into
garyhtou wants to merge 8 commits into
Conversation
| <%# The subject can be the secret itself; login codes are in it. The | ||
| mailer and action stand in as a hint at what the email was. %> | ||
| <%= link_to "#", class: "muted flex items-center gap-1", | ||
| data: { behavior: "modal_trigger", modal: "message_#{msg.id}" } do %> |
Contributor
There was a problem hiding this comment.
Suggested change
| data: { behavior: "modal_trigger", modal: "message_#{msg.id}" } do %> | |
| data: { behavior: "modal_trigger", modal: "message_#{msg.id}" } do %> |
Login codes, Google Workspace passwords, and email change tokens are all rendered into emails that we retain in `ahoy_messages`. Add an opt-in `has_sensitive_contents` declaration to `ApplicationMailer` that stamps a `sensitive` flag onto the stored message, and backfill existing rows. Nothing changes about what is stored. The flag is what the admin email viewer will use to decide who may read the contents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The admin email viewer is reachable by auditors, the read-only role, and it renders the stored copy of every email verbatim. Login code emails are among them, so anyone with auditor access could request a code for another user's address and read it straight off the page, signing in as any user without 2FA and leaving no attribution behind. Deny `#email` and `#email_html` for messages flagged sensitive unless the requester is a superadmin. This uses `superadmin_signed_in?` rather than the model predicate so that an impersonated superadmin session is also denied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The login code is in the subject line, so gating the contents alone still left it readable on the index page. Restricted rows now render the mailer and action in place of the subject, which keeps a hint of what the email was without disclosing it, and they no longer open a modal. Extract `restricted_email?` as the single rule the controller guard and the views both consult, so the two cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pg_search matches against the plaintext subject column, so masking the subject in the table still left a confirmation oracle: search for a code, read the result count. Drop sensitive rows from search results for non-superadmins. They stay in the unfiltered and user/recipient filtered listings, so "did we send them a login code, and when" is still answerable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`has_sensitive_contents` is opt in, so a mailer added later is readable by every auditor unless someone notices. Pin the current set of mailer actions so adding one fails the suite with instructions, rather than silently defaulting to visible. The list is a tripwire, not a certification that all 151 entries were individually audited. Its job is to force a decision on anything new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Restore the modal trigger on restricted rows. Removing it made the explanation of the restriction unreachable, so an auditor saw only a lock icon and an internal class name with no way to find out why. - Move the guard to a before_action, so the actions no longer depend on the caller remembering to check a return value. - Flip GSuiteAccountMailer to `except: [:verify]` so a new action added to a mailer already known to send passwords defaults to restricted. - Reject a caller-supplied `extra:`, which would replace the flag and turn the declaration into a silent no-op. - Backfill the pre-rename UserMailer email update actions. - Make the backfill's `down` a no-op. Un-flagging would have cleared rows written by the running application, re-exposing live login codes. - Pin the search exclusion properly. The superadmin control used a different query, so the test would have passed with the exclusion deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the backfill out of a migration. It touches a large table and wants the pausing, throttling and resumability the maintenance task runner gives, and it can be re-run after the release is live to pick up anything sent during the deploy window. Also fixes hash alignment in the restricted row's link_to, which erb_lint catches but bin/lint does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds Payroll::PositionMailer#terminated, which sends a position title and a termination notice with no token or link, so it stays visible. Drops AdminMailer#logical_transaction_anomalies, which no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
garyhtou
force-pushed
the
worktree-auditor-admin-emails
branch
from
August 20, 2026 17:33
d4353a2 to
11285a9
Compare
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 of the problem
The admin email viewer renders the stored copy of every email HCB sends. The admin area is gated by
signed_in_admin, which resolves toauditor_signed_in?, so the read-only auditor role could open any of them.LoginCodeMailer#send_codeputs the login code in the subject as well as the body, and the subject is stored unencrypted and exposed through search, so the code was visible on the index page without opening anything.The 15 minute code expiry gave no protection, because the attacker chooses when the code is generated: enter a victim's email at
/logins, then read the fresh code off the admin page. Unlike impersonation, which stampsuser_sessions.impersonated_by, the resulting session is indistinguishable from the real user's.Two other mailers store secrets in the same table:
GSuiteAccountMailer(plaintext Google Workspace passwords, which do not expire) andUser::EmailUpdateMailer(email change tokens).Describe your changes
Mailers opt in with
has_sensitive_contents, a thin wrapper overahoy_email'shas_historythat stamps a newahoy_messages.sensitivecolumn at delivery. Existing rows are backfilled. Every admin read path then checkssuperadmin_signed_in?before disclosing anything:#emailand#email_htmlreturn 403 for a flagged message unless the viewer is a superadmin. This is the fix; the rest is presentation.LoginCodeMailer#send_code) in place of the subject, and its modal explains the restriction rather than loading the email.superadmin_signed_in?is used rather thancurrent_user.superadmin?because it also rejects impersonated sessions, so an admin impersonating a superadmin is denied.Nothing changes about what is stored. Contents stay
encrypts-protected and fully readable from the Rails console, and superadmins keep access in the UI.Since marking is opt in, a new secret-bearing mailer would default to visible.
spec/mailers/sensitive_contents_coverage_spec.rbpins the current set of mailer actions so adding one fails the suite with instructions. It is a tripwire to force a decision on anything new, not a certification that all 151 existing entries were individually audited.Deploy order
Maintenance::BackfillSensitiveAhoyMessagesTaskto flag historical rows, then run it again once the release is live to catch anything sent during the deploy window.The migration must land before the code.
AhoyEmail::Trackerwraps message creation inSafely.safely, so if the code ships first, assigning the unknown attribute is swallowed and noAhoy::Messagerow is written at all for these mailers. That fails closed for confidentiality but silently loses the record that the email was sent.Backfilling from a maintenance task rather than a migration keeps a long update over a large table out of the deploy, and makes it pausable, throttleable and re-runnable.
This does not close the underlying hole
Auditors also have Blazer (
config/routes.rb,constraints AuditorConstraint), which runs arbitrary SQL against the app's own database with no table restrictions.login_codes.codeis stored in plaintext, so the same actor can skip the email viewer entirely and read live codes with a query.ahoy_messages.subjectis a second path to the same secret.This PR is still worth landing, since the email viewer was exposing Workspace passwords and email change tokens as well, and to more people. But the account takeover path stays open until login codes stop being readable as plaintext, or Blazer stops running as a role that can read them. Raising that separately.
Testing
20 new examples covering the flag being written at delivery, 403s for auditor / admin / impersonated-superadmin and a 200 for a genuine superadmin, the subject not appearing in the index for non-superadmins, and the search exclusion. The neighbouring mailer and admin suites still pass.