1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Impersonation (#1325)

* Initial impersonation

* Impersonation audit

* Keep super admin separate

* Remove vscode settings

* Comment cleanup

* Comment out impersonation fixtures for now

* Remove unused controlelr

* Add impersonation testing (#1326)

* Add impersonation testing

* Remove unused method

* Update schema.rb

* Update brakeman

---------

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
This commit is contained in:
Josh Pigford 2024-10-18 11:26:58 -05:00 committed by GitHub
parent 4a3685f503
commit c7c281073f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 477 additions and 16 deletions

1
.gitignore vendored
View file

@ -44,7 +44,6 @@
# Ignore VS Code
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
!.vscode/*.code-snippets

View file

@ -1,6 +0,0 @@
{
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
}
}

View file

@ -110,7 +110,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.2.1)
brakeman (6.2.2)
racc
builder (3.3.0)
capybara (3.40.0)

View file

@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Pagy::Backend
private

View file

@ -14,7 +14,7 @@ module Authentication
private
def authenticate_user!
if session_record = Session.find_by_id(cookies.signed[:session_token])
if session_record = find_session_by_cookie
Current.session = session_record
else
if self_hosted_first_login?
@ -25,6 +25,10 @@ module Authentication
end
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_token])
end
def create_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }

View file

@ -0,0 +1,21 @@
module Impersonatable
extend ActiveSupport::Concern
included do
after_action :create_impersonation_session_log
end
private
def create_impersonation_session_log
return unless Current.session&.active_impersonator_session.present?
Current.session.active_impersonator_session.logs.create!(
controller: controller_name,
action: action_name,
path: request.fullpath,
method: request.method,
ip_address: request.ip,
user_agent: request.user_agent
)
end
end

View file

@ -0,0 +1,58 @@
class ImpersonationSessionsController < ApplicationController
before_action :require_super_admin!, only: [ :create, :join, :leave ]
before_action :set_impersonation_session, only: [ :approve, :reject, :complete ]
def create
Current.true_user.request_impersonation_for(session_params[:impersonated_id])
redirect_to root_path, notice: t(".success")
end
def join
@impersonation_session = Current.true_user.impersonator_support_sessions.find_by(id: params[:impersonation_session_id])
Current.session.update!(active_impersonator_session: @impersonation_session)
redirect_to root_path, notice: t(".success")
end
def leave
Current.session.update!(active_impersonator_session: nil)
redirect_to root_path, notice: t(".success")
end
def approve
raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
@impersonation_session.approve!
redirect_to root_path, notice: t(".success")
end
def reject
raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
@impersonation_session.reject!
redirect_to root_path, notice: t(".success")
end
def complete
@impersonation_session.complete!
redirect_to root_path, notice: t(".success")
end
private
def session_params
params.require(:impersonation_session).permit(:impersonated_id)
end
def set_impersonation_session
@impersonation_session =
Current.true_user.impersonated_support_sessions.find_by(id: params[:id]) ||
Current.true_user.impersonator_support_sessions.find_by(id: params[:id])
end
def require_super_admin!
raise_unauthorized! unless Current.true_user&.super_admin?
end
def raise_unauthorized!
raise ActionController::RoutingError.new("Not Found")
end
end

View file

@ -0,0 +1,2 @@
module ImpersonationSessionsHelper
end

View file

@ -1,7 +1,19 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :user_agent, :ip_address
delegate :user, to: :session, allow_nil: true
attribute :session
delegate :family, to: :user, allow_nil: true
def user
impersonated_user || session&.user
end
def impersonated_user
session&.active_impersonator_session&.impersonated
end
def true_user
session&.user
end
end

View file

@ -0,0 +1,39 @@
class ImpersonationSession < ApplicationRecord
belongs_to :impersonator, class_name: "User"
belongs_to :impersonated, class_name: "User"
has_many :logs, class_name: "ImpersonationSessionLog"
enum :status, { pending: "pending", in_progress: "in_progress", complete: "complete", rejected: "rejected" }
scope :initiated, -> { where(status: [ :pending, :in_progress ]) }
validate :impersonator_is_super_admin
validate :impersonated_is_not_super_admin
validate :impersonator_different_from_impersonated
def approve!
update! status: :in_progress
end
def reject!
update! status: :rejected
end
def complete!
update! status: :complete
end
private
def impersonator_is_super_admin
errors.add(:impersonator, "must be a super admin to impersonate") unless impersonator.super_admin?
end
def impersonated_is_not_super_admin
errors.add(:impersonated, "cannot be a super admin") if impersonated.super_admin?
end
def impersonator_different_from_impersonated
errors.add(:impersonator, "cannot be the same as the impersonated user") if impersonator == impersonated
end
end

View file

@ -0,0 +1,3 @@
class ImpersonationSessionLog < ApplicationRecord
belongs_to :impersonation_session
end

View file

@ -1,5 +1,9 @@
class Session < ApplicationRecord
belongs_to :user
belongs_to :active_impersonator_session,
-> { where(status: :in_progress) },
class_name: "ImpersonationSession",
optional: true
before_create do
self.user_agent = Current.user_agent

View file

@ -3,6 +3,8 @@ class User < ApplicationRecord
belongs_to :family
has_many :sessions, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
accepts_nested_attributes_for :family
validates :email, presence: true, uniqueness: true
@ -11,7 +13,7 @@ class User < ApplicationRecord
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }
enum :role, { member: "member", admin: "admin" }, validate: true
enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true
has_one_attached :profile_image do |attachable|
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ]
@ -23,6 +25,11 @@ class User < ApplicationRecord
password_salt&.last(10)
end
def request_impersonation_for(user_id)
impersonated = User.find(user_id)
impersonator_support_sessions.create!(impersonated: impersonated)
end
def display_name
[ first_name, last_name ].compact.join(" ").presence || email
end

View file

@ -0,0 +1,21 @@
<% pending_session = Current.true_user.impersonated_support_sessions.pending.first %>
<% in_progress_session = Current.true_user.impersonated_support_sessions.in_progress.first %>
<div class="sticky top-0 left-0 w-full bg-black flex items-center justify-between font-mono">
<div class="flex items-center bg-red-600 px-6 py-4">
<%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %>
<span class="text-white font-semibold uppercase">Access <%= in_progress_session.present? ? "Session" : "Request" %></span>
</div>
<div class="flex items-center space-x-2 px-2 py-2 text-white gap-4">
<% if pending_session.present? %>
<p class="text-xs max-w-3xl text-right">Maybe support staff has requested access to your account (likely to help you with a support request). If you approve the request, all activity they take will be logged for security and audit purposes.</p>
<%= button_to "Approve", approve_impersonation_session_path(pending_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %>
<%= button_to "Reject", reject_impersonation_session_path(pending_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% elsif in_progress_session.present? %>
<p class="text-xs max-w-3xl text-right">Someone from the Maybe Finance team is currently viewing your data. You may end the session at any time.</p>
<%= button_to "End Session", complete_impersonation_session_path(in_progress_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<% else %>
<p class="text-xs max-w-3xl text-right text-red-500">Something went wrong. Please contact us.</p>
<% end %>
</div>
</div>

View file

@ -0,0 +1,35 @@
<div class="sticky top-0 left-0 w-full bg-black flex items-center justify-between font-mono">
<div class="flex items-center bg-red-600 px-6 py-4">
<%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %>
<span class="text-white font-semibold uppercase">Super Admin</span>
</div>
<div class="flex items-center space-x-2 px-2 py-2 text-white">
<% if Current.session.active_impersonator_session.present? %>
<div class="flex items-center space-x-3 bg-gray-800 border border-gray-700 rounded-md pl-3">
<div class="text-sm">
Impersonating: <span class="font-semibold text-red-400"><%= Current.impersonated_user.email %></span>
</div>
<%= button_to "Leave", leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= button_to "Terminate", complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
</div>
<% else %>
<% if Current.true_user.impersonator_support_sessions.in_progress.any? %>
<%= form_with url: join_impersonation_sessions_path, class: "flex items-center space-x-2 mr-4" do |f| %>
<%= f.select :impersonation_session_id,
Current.true_user.impersonator_support_sessions.in_progress.map { |session|
["#{session.impersonated.email} (#{session.status})", session.id]
},
{ prompt: "Join a session" },
{ class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono" } %>
<%= f.submit "Join",
class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
<% end %>
<%= form_with model: ImpersonationSession.new, class: "flex items-center space-x-2" do |f| %>
<%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: "UUID", autocomplete: "off" %>
<%= f.submit "Request Impersonation", class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
<% end %>
</div>
</div>

View file

@ -25,6 +25,9 @@
</head>
<body class="h-full">
<%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? %>
<%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %>
<div class="fixed z-50 space-y-1 top-6 right-10">
<div id="notification-tray">
<%= render_flash_notifications %>

View file

@ -2,4 +2,4 @@
<div class="text-white font-normal">
<%= tooltip_text %>
</div>
</div>
</div>

View file

@ -0,0 +1,15 @@
---
en:
impersonation_sessions:
create:
success: "Request sent to user. Waiting for approval."
join:
success: "Joined session"
leave:
success: "Left session"
approve:
success: "Request approved"
reject:
success: "Request rejected"
complete:
success: "Session completed"

View file

@ -110,6 +110,17 @@ Rails.application.routes.draw do
resources :currencies, only: %i[show]
resources :impersonation_sessions, only: [ :create ] do
post :join, on: :collection
delete :leave, on: :collection
member do
put :approve
put :reject
put :complete
end
end
# Stripe webhook endpoint
post "webhooks/stripe", to: "webhooks#stripe"

View file

@ -0,0 +1,23 @@
class AddSuperAdminToUsers < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
change_column :users, :role, :string, default: 'member'
execute <<-SQL
DROP TYPE user_role;
SQL
end
dir.down do
execute <<-SQL
CREATE TYPE user_role AS ENUM ('admin', 'member');
SQL
change_column_default :users, :role, nil
change_column :users, :role, :user_role, using: 'role::user_role'
change_column_default :users, :role, 'member'
end
end
end
end

View file

@ -0,0 +1,12 @@
class CreateImpersonationSessions < ActiveRecord::Migration[7.2]
def change
create_table :impersonation_sessions, id: :uuid do |t|
t.references :impersonator, null: false, foreign_key: { to_table: :users }, type: :uuid
t.references :impersonated, null: false, foreign_key: { to_table: :users }, type: :uuid
t.string :status, null: false, default: 'pending'
t.timestamps
end
add_reference :sessions, :active_impersonator_session, type: :uuid, foreign_key: { to_table: :impersonation_sessions }
end
end

View file

@ -0,0 +1,14 @@
class CreateImpersonationSessionLogs < ActiveRecord::Migration[7.2]
def change
create_table :impersonation_session_logs, id: :uuid do |t|
t.references :impersonation_session, type: :uuid, foreign_key: true, null: false
t.string :controller
t.string :action
t.text :path
t.string :method
t.string :ip_address
t.text :user_agent
t.timestamps
end
end
end

6
db/schema.rb generated
View file

@ -19,7 +19,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
# Note that some types may not work with other database engines. Be careful if changing database.
create_enum "account_status", ["ok", "syncing", "error"]
create_enum "import_status", ["pending", "importing", "complete", "failed"]
create_enum "user_role", ["admin", "member"]
create_enum "user_role", ["admin", "member", "super_admin"]
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
@ -493,6 +493,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
t.string "ip_address"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "active_impersonator_session_id"
t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id"
t.index ["user_id"], name: "index_sessions_on_user_id"
end
@ -535,7 +537,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
t.string "last_alerted_upgrade_commit_sha"
t.enum "role", default: "member", null: false, enum_type: "user_role"
t.boolean "active", default: true, null: false
t.boolean "super_admin", default: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
end
@ -573,6 +574,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
add_foreign_key "imports", "families"
add_foreign_key "institutions", "families"
add_foreign_key "merchants", "families"
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
add_foreign_key "sessions", "users"
add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families"

View file

@ -0,0 +1,112 @@
require "test_helper"
class ImpersonationSessionsControllerTest < ActionDispatch::IntegrationTest
test "impersonation session logs all activity for auditing" do
sign_in impersonator = users(:maybe_support_staff)
impersonated = users(:family_member)
impersonator_session = impersonation_sessions(:in_progress)
post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id }
assert_difference "impersonator_session.logs.count", 2 do
get root_path
get account_path(impersonated.family.accounts.first)
end
end
test "super admin can request an impersonation session" do
sign_in users(:maybe_support_staff)
post impersonation_sessions_path, params: { impersonation_session: { impersonated_id: users(:family_member).id } }
assert_equal "Request sent to user. Waiting for approval.", flash[:notice]
assert_redirected_to root_path
end
test "super admin can join and leave an in progress impersonation session" do
sign_in super_admin = users(:maybe_support_staff)
impersonator_session = impersonation_sessions(:in_progress)
super_admin_session = super_admin.sessions.order(created_at: :desc).first
assert_nil super_admin_session.active_impersonator_session
# Joining the session
post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id }
assert_equal impersonator_session, super_admin_session.reload.active_impersonator_session
assert_equal "Joined session", flash[:notice]
assert_redirected_to root_path
follow_redirect!
# Leaving the session
delete leave_impersonation_sessions_path
assert_nil super_admin_session.reload.active_impersonator_session
assert_equal "Left session", flash[:notice]
assert_redirected_to root_path
# Impersonation session still in progress because nobody has ended it yet
assert_equal "in_progress", impersonator_session.reload.status
end
test "super admin can complete an impersonation session" do
sign_in super_admin = users(:maybe_support_staff)
impersonator_session = impersonation_sessions(:in_progress)
put complete_impersonation_session_path(impersonator_session)
assert_equal "Session completed", flash[:notice]
assert_nil super_admin.sessions.order(created_at: :desc).first.active_impersonator_session
assert_equal "complete", impersonator_session.reload.status
assert_redirected_to root_path
end
test "regular user can complete an impersonation session" do
sign_in regular_user = users(:family_member)
impersonator_session = impersonation_sessions(:in_progress)
put complete_impersonation_session_path(impersonator_session)
assert_equal "Session completed", flash[:notice]
assert_equal "complete", impersonator_session.reload.status
assert_redirected_to root_path
end
test "super admin cannot accept an impersonation session" do
sign_in super_admin = users(:maybe_support_staff)
impersonator_session = impersonation_sessions(:in_progress)
put approve_impersonation_session_path(impersonator_session)
assert_response :not_found
end
test "regular user can accept an impersonation session" do
sign_in regular_user = users(:family_member)
impersonator_session = impersonation_sessions(:in_progress)
put approve_impersonation_session_path(impersonator_session)
assert_equal "Request approved", flash[:notice]
assert_equal "in_progress", impersonator_session.reload.status
assert_redirected_to root_path
end
test "regular user can reject an impersonation session" do
sign_in regular_user = users(:family_member)
impersonator_session = impersonation_sessions(:in_progress)
put reject_impersonation_session_path(impersonator_session)
assert_equal "Request rejected", flash[:notice]
assert_equal "rejected", impersonator_session.reload.status
assert_redirected_to root_path
end
end

View file

@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# This model initially had no columns defined. If you add columns to the
# model remove the "{}" from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
#one: {}
# column: value
#
#two: {}
# column: value

View file

@ -0,0 +1,4 @@
in_progress:
impersonator: maybe_support_staff
impersonated: family_member
status: in_progress

View file

@ -5,6 +5,14 @@ empty:
email: user1@email.com
password_digest: <%= BCrypt::Password.create('password') %>
maybe_support_staff:
family: empty
first_name: Support
last_name: Admin
email: support@maybe.co
password_digest: <%= BCrypt::Password.create('password') %>
role: super_admin
family_admin:
family: dylan_family
first_name: Bob

View file

@ -0,0 +1,7 @@
require "test_helper"
class ImpersonationSessionLogTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,40 @@
require "test_helper"
class ImpersonationSessionTest < ActiveSupport::TestCase
test "only super admin can impersonate" do
regular_user = users(:family_member)
assert_not regular_user.super_admin?
assert_raises(ActiveRecord::RecordInvalid) do
ImpersonationSession.create!(
impersonator: regular_user,
impersonated: users(:maybe_support_staff)
)
end
end
test "super admin cannot be impersonated" do
super_admin = users(:maybe_support_staff)
assert super_admin.super_admin?
assert_raises(ActiveRecord::RecordInvalid) do
ImpersonationSession.create!(
impersonator: users(:family_member),
impersonated: super_admin
)
end
end
test "impersonation session must have different impersonator and impersonated" do
super_admin = users(:maybe_support_staff)
assert_raises(ActiveRecord::RecordInvalid) do
ImpersonationSession.create!(
impersonator: super_admin,
impersonated: super_admin
)
end
end
end