mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-04 21:15:19 +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:
parent
4a3685f503
commit
c7c281073f
29 changed files with 477 additions and 16 deletions
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
21
app/controllers/concerns/impersonatable.rb
Normal file
21
app/controllers/concerns/impersonatable.rb
Normal 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
|
58
app/controllers/impersonation_sessions_controller.rb
Normal file
58
app/controllers/impersonation_sessions_controller.rb
Normal 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
|
2
app/helpers/impersonation_sessions_helper.rb
Normal file
2
app/helpers/impersonation_sessions_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module ImpersonationSessionsHelper
|
||||
end
|
|
@ -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
|
||||
|
|
39
app/models/impersonation_session.rb
Normal file
39
app/models/impersonation_session.rb
Normal 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
|
3
app/models/impersonation_session_log.rb
Normal file
3
app/models/impersonation_session_log.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class ImpersonationSessionLog < ApplicationRecord
|
||||
belongs_to :impersonation_session
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
21
app/views/impersonation_sessions/_approval_bar.html.erb
Normal file
21
app/views/impersonation_sessions/_approval_bar.html.erb
Normal 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>
|
35
app/views/impersonation_sessions/_super_admin_bar.html.erb
Normal file
35
app/views/impersonation_sessions/_super_admin_bar.html.erb
Normal 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>
|
|
@ -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 %>
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
<div class="text-white font-normal">
|
||||
<%= tooltip_text %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue