Many support tools eventually grow a "log in as this customer" feature. Ours had a problem the usual gems don't solve. The people on the other side of the button are customers of a service containing sensitive user data. Delist My Data clients give us the history of their personal information that we will work to have taken down across the web. We want our admins to be restricted from ordering data scans or altering legal authorizations the users grant us to act as their agents for this process. We also wanted our customers to see a transparent notice that administrators had viewed their account information in this manner.
We built it and ran it in production. Last week we pulled it out into a gem called viewing_as. This post is about the decisions we made that may transfer to your app as well.
What the existing gems do
pretender is about fifty lines. It stores the impersonated user id in session[] and overrides current_user, with true_user for the real person. When all you need is the swap, it's the right tool and we'd have used it. devise_masquerade does the same thing behind a signed link, for Devise. switch_user is a dropdown for development and says so.
None of them refuse writes, or keep a log a customer could read. None of them time out or re-check anything once the session has started.
The Rails 8 authentication generator ships no impersonation at all, and the tutorials that add one keep the state in the session with an expiry and leave audit and read-only as an exercise.
Read-only, in two layers
The first layer is a before_action that refuses requests that aren't GET or HEAD while a viewing session is active. It reads the method off the Rack env, which is the same value the router dispatched on, so a POST wearing _method=get can't get a write action past it.
def refuse_writes_while_impersonating
return unless impersonation_read_only?
return if request.env["REQUEST_METHOD"].in?(%w[ GET HEAD ])
render plain: "Read-only while viewing another account.", status: :forbidden
end
The second layer is for what the first can't see. A GET that calls update_column in a helper. A counter cache. A touch-on-read somebody adds next year. The action runs inside ActiveRecord::Base.while_preventing_writes, and a write that reaches it is logged at error level, because it's a bug rather than a user action.
def read_only_while_impersonating
return yield unless impersonation_read_only?
ActiveRecord::Base.while_preventing_writes { yield }
rescue ActiveRecord::ReadOnlyError => e
logger.error("[ViewingAs] blocked a write: #{e.message}")
render plain: "Read-only while viewing another account.", status: :forbidden
end
Read-only is the default, and it's a setting. A support team that fixes addresses as the customer wants the same log and the same leash with writes allowed, so c.read_only = false turns it off for everyone, and impersonate(user, read_only: false) turns it off for one session. That choice gets passed into the signed cookie when the session starts. Nothing in the browser can flip it afterwards. The log will say writable if the session is not launched in read-only mode, so the customer has an audit log.
The bug every session-based version has
Say the session expires while the admin is mid-click, or the customer withdraws consent between two requests. The natural code path drops the impersonation and carries on. The write guard now sees nobody being viewed, so it waves the request through, and current_user has quietly become the admin. The click was aimed at the customer's account. It lands on the admin's own.
In our app the routes that matter are singular resources with no id in the path, so there was nothing to fail safe on. "Withdraw authorization" at minute thirty-one would have withdrawn the admin's.
The fix is that ending a session mid-request refuses the request if it was going to write:
def finish_impersonation(kind, subject)
end_impersonation!(kind, subject)
return if request.env["REQUEST_METHOD"].in?(%w[ GET HEAD ])
return if true_user.nil?
render plain: "That session ended before this went through. Nothing was changed.",
status: :conflict
end
This holds in writable sessions too.
Re-validated on every request
The cookie is signed. It names the target and the admin's own session, and it carries a start timestamp. On every request the gem re-reads from the database whether the admin is still an admin, whether that exact session still exists, and whether the target still exists and still permits it. A "no" from any of those ends the session and logs why. It takes effect on the next click, without anyone reaching into a browser.
Consent is a lambda the host provides. Ours checks a column the customer flips from their own account page:
ViewingAs.configure do |c|
c.may_be_viewed = lambda do |target, _admin|
if target.admin? then "That account is an administrator."
elsif !target.admin_review_permitted? then "That customer has withdrawn permission for review."
else true
end
end
end
The String is what the admin sees and what the refusal row in the log says.
Why a cookie and not the session
Many of our pages are cached at CloudFront. The header on every page calls authenticated?, so the sign-in check already runs on cached pages, and it costs nothing because it reads a signed cookie. Loading session[] is different. Rack writes Set-Cookie, and a response carrying Set-Cookie can't be marked public. Keeping impersonation state in the session would have quietly un-cached the whole content site to serve a feature two people use. That's the concrete reason pretender didn't fit here, and it's why the gem reads a second signed cookie instead.
The cookie is also given no expires. The timeout is enforced server-side from the start timestamp. A cookie that expired in the browser first would never be seen by the server, so the expiry would never reach the log, and the customer would see a session that started and apparently never ended.
The log the customer reads
Each row is written for the person whose account it is: "[X] started viewing your account as you see it", "Stopped viewing your account (permission ended)", "Tried to view your account and was refused". Each row records the admin's address at the time, so it outlives the admin's account, and readonly? is true once persisted so ActiveRecord can't edit it.
Using it
gem "viewing_as"
bin/rails generate viewing_as:install
bin/rails db:migrate
The generator writes three files and adds two routes. It includes the concern in ApplicationController after Authentication. It prepends a mixin into Current so that Current.user answers with the viewed account, and it renders a banner at the top of the layout. Rails 7.2 through 8.1 are on CI.
The specs are the part I'd read first. Almost every one of them is a refusal.
Top comments (0)