Skip to content

[Admin] Restrict sensitive email contents to superadmins - #14606

Draft
garyhtou wants to merge 8 commits into
mainfrom
worktree-auditor-admin-emails
Draft

[Admin] Restrict sensitive email contents to superadmins#14606
garyhtou wants to merge 8 commits into
mainfrom
worktree-auditor-admin-emails

Conversation

@garyhtou

@garyhtou garyhtou commented Aug 13, 2026

Copy link
Copy Markdown
Member

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 to auditor_signed_in?, so the read-only auditor role could open any of them.

LoginCodeMailer#send_code puts 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 stamps user_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) and User::EmailUpdateMailer (email change tokens).

Describe your changes

Mailers opt in with has_sensitive_contents, a thin wrapper over ahoy_email's has_history that stamps a new ahoy_messages.sensitive column at delivery. Existing rows are backfilled. Every admin read path then checks superadmin_signed_in? before disclosing anything:

  • #email and #email_html return 403 for a flagged message unless the viewer is a superadmin. This is the fix; the rest is presentation.
  • The index renders the mailer and action (LoginCodeMailer#send_code) in place of the subject, and its modal explains the restriction rather than loading the email.
  • Flagged rows are excluded from subject search for non-superadmins. The subject is plaintext and searchable, so without this you could confirm a live code from the result count alone.

superadmin_signed_in? is used rather than current_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.rb pins 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

  1. Run the migration. It only adds a column with a constant default, so there is no table rewrite.
  2. Deploy the code.
  3. Run Maintenance::BackfillSensitiveAhoyMessagesTask to 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::Tracker wraps message creation in Safely.safely, so if the code ships first, assigning the unknown attribute is swallowed and no Ahoy::Message row 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.code is stored in plaintext, so the same actor can skip the email viewer entirely and read live codes with a query. ahoy_messages.subject is 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.

<%# 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 %>

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.

Suggested change
data: { behavior: "modal_trigger", modal: "message_#{msg.id}" } do %>
data: { behavior: "modal_trigger", modal: "message_#{msg.id}" } do %>

garyhtou and others added 8 commits August 20, 2026 10:29
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
garyhtou force-pushed the worktree-auditor-admin-emails branch from d4353a2 to 11285a9 Compare August 20, 2026 17:33
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.

1 participant