[Backend] Backend::User delenda est. (#66)

kill me
This commit is contained in:
nora 2025-12-03 00:45:07 -05:00 committed by GitHub
parent 7d0a98ab11
commit ca58cc3bec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 297 additions and 262 deletions

View file

@ -2,6 +2,7 @@ module Backend
class ApplicationController < ActionController::Base
include PublicActivity::StoreController
include Pundit::Authorization
include ::SessionsHelper
layout "backend"
@ -10,15 +11,16 @@ module Backend
helper_method :current_user, :user_signed_in?
before_action :authenticate_user!, :set_honeybadger_context
before_action :require_2fa!
before_action :set_paper_trail_whodunnit
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
@current_user ||= current_identity&.backend_user
end
def current_impersonator
@current_impersonator ||= User.find_by(id: session[:impersonator_user_id]) if session[:impersonator_user_id]
@current_impersonator ||= Identity.find_by(id: session[:impersonator_user_id])&.backend_user if session[:impersonator_user_id]
end
alias_method :find_current_auditor, :current_user
@ -30,20 +32,28 @@ module Backend
def user_signed_in? = !!current_user
def authenticate_user!
unless user_signed_in?
return redirect_to backend_login_path, alert: ("you need to be logged in!")
unless current_identity
session[:return_to] = request.original_url
return redirect_to root_path, alert: "Please log in to access the backend."
end
unless @current_user&.active?
session[:user_id] = nil
@current_user = nil
redirect_to backend_login_path, alert: ("you need to be logged in!")
unless current_user&.active?
redirect_to root_path, alert: "You do not have access to the backend."
end
end
def require_2fa!
unless current_identity&.use_two_factor_authentication?
redirect_to root_path, alert: "You must enable Two-Factor Authentication to access the backend."
end
end
def set_honeybadger_context
Honeybadger.context({
user_id: current_user&.id,
user_username: current_user&.username
user_username: current_user&.username,
identity_id: current_identity&.id,
identity_email: current_identity&.primary_email
})
end

View file

@ -1,5 +1,5 @@
module Backend
class NoAuthController < ApplicationController
class NoAuthController < Backend::ApplicationController
skip_before_action :authenticate_user!
end
end

View file

@ -1,62 +0,0 @@
module Backend
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: [ :new, :create, :fake_slack_callback_for_dev ]
skip_after_action :verify_authorized
def new
redirect_uri = url_for(action: :create, only_path: false)
redirect_to User.authorize_url(redirect_uri),
host: "https://slack.com",
allow_other_host: true
end
def create
redirect_uri = url_for(action: :create, only_path: false)
if params[:error].present?
uuid = Honeybadger.notify("Slack OAuth error: #{params[:error]}")
redirect_to backend_login_path, alert: "failed to authenticate with Slack! (error: #{uuid})"
return
end
begin
@user = User.from_slack_token(params[:code], redirect_uri)
rescue => e
uuid = Honeybadger.notify(e)
redirect_to backend_login_path, alert: "error authenticating! (error: #{uuid})"
return
end
if @user&.persisted?
session[:user_id] = @user.id
flash[:success] = "welcome aboard!"
redirect_to backend_root_path
else
redirect_to backend_login_path, alert: "you haven't been provisioned an account on this service yet this attempt been logged."
end
end
def fake_slack_callback_for_dev
unless Rails.env.development?
Honeybadger.notify("Fake Slack callback attempted in non-development environment. WTF?!")
redirect_to backend_root_path, alert: "this is only available in development mode."
return
end
@user = User.find_by(slack_id: params[:slack_id], active: true)
if @user.nil?
redirect_to backend_root_path, alert: "dunno who that is, sorry."
return
end
session[:user_id] = @user.id
redirect_to backend_root_path, notice: "welcome aboard!"
end
def destroy
session[:user_id] = nil
redirect_to backend_root_path, notice: "bye, see you next time!"
end
end
end

View file

@ -5,11 +5,17 @@ module Backend
def index
authorize Backend::User
@users = User.all
@users = @users.left_joins(:identity).where("identities.primary_email ILIKE :q OR identities.first_name ILIKE :q OR identities.last_name ILIKE :q OR users.username ILIKE :q", q: "%#{params[:search]}%") if params[:search].present?
@users = @users.includes(:identity, :organized_programs)
end
def new
authorize User
@user = User.new
@identities = if params[:query].present?
Identity.search(params[:query]).where.not(id: User.linked.select(:identity_id)).limit(20)
else
Identity.none
end
end
def edit
@ -26,11 +32,28 @@ module Backend
def create
authorize User
@user = User.new(new_user_params.merge(active: true))
unless params[:identity_id].present?
redirect_to new_backend_user_path, alert: "No identity selected"
return
end
identity = Identity.find(params[:identity_id])
if User.exists?(identity_id: identity.id)
redirect_to backend_users_path, alert: "This identity already has backend access!"
return
end
@user = User.new(user_params.merge(
identity: identity,
username: "#{identity.first_name} #{identity.last_name}".strip,
active: true
))
if @user.save
redirect_to backend_users_path, notice: "User created!"
redirect_to backend_user_path(@user), notice: "Backend access granted to #{identity.primary_email}!"
else
render :new
redirect_to new_backend_user_path, alert: @user.errors.full_messages.join(", ")
end
end
@ -65,9 +88,5 @@ module Backend
def user_params
params.require(:backend_user).permit(:username, :icon_url, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, organized_program_ids: [])
end
def new_user_params
params.require(:backend_user).permit(:slack_id, :username, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, organized_program_ids: [])
end
end
end

View file

@ -1,136 +1,67 @@
# == Schema Information
#
# Table name: backend_users
#
# id :bigint not null, primary key
# active :boolean
# all_fields_access :boolean
# can_break_glass :boolean
# human_endorser :boolean
# icon_url :string
# manual_document_verifier :boolean
# program_manager :boolean
# super_admin :boolean
# username :string
# created_at :datetime not null
# updated_at :datetime not null
# credential_id :string
# slack_id :string
#
# Indexes
#
# index_backend_users_on_slack_id (slack_id)
#
class Backend::User < ApplicationRecord
has_paper_trail
module Backend
class User < ApplicationRecord
self.table_name = "backend_users"
# Organizer positions - programs this backend user organizes
has_many :organizer_positions, class_name: "Backend::OrganizerPosition", foreign_key: "backend_user_id", dependent: :destroy
has_many :organized_programs, through: :organizer_positions, source: :program, class_name: "Program"
belongs_to :identity, optional: true
def self.authorize_url(redirect_uri)
params = {
client_id: ENV["SLACK_CLIENT_ID"],
redirect_uri: redirect_uri,
state: SecureRandom.hex(24),
user_scope: "users.profile:read,users:read,users:read.email"
}
has_many :organizer_positions, class_name: "Backend::OrganizerPosition", foreign_key: "backend_user_id", dependent: :destroy
has_many :organized_programs, through: :organizer_positions, source: :program, class_name: "Program"
URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}")
end
validates :username, presence: true, uniqueness: true, if: :orphaned?
def self.from_slack_token(code, redirect_uri)
# Exchange code for token
response = HTTP.post("https://slack.com/api/oauth.v2.access", form: {
client_id: ENV["SLACK_CLIENT_ID"],
client_secret: ENV["SLACK_CLIENT_SECRET"],
code: code,
redirect_uri: redirect_uri
})
delegate :first_name, :last_name, :slack_id, to: :identity, allow_nil: true
data = JSON.parse(response.body.to_s)
scope :orphaned, -> { where(identity_id: nil) }
scope :linked, -> { where.not(identity_id: nil) }
return nil unless data["ok"]
def orphaned? = identity_id.nil?
# Get users info
user_response = HTTP.auth("Bearer #{data["authed_user"]["access_token"]}")
.get("https://slack.com/api/users.info?user=#{data["authed_user"]["id"]}")
def email = identity&.primary_email
user_data = JSON.parse(user_response.body.to_s)
return nil unless user_data["ok"]
slack_id = data.dig("authed_user", "id")
user = find_by(slack_id:)
unless user
Honeybadger.notify("User #{slack_id} tried to sign into the backend without an account")
return nil
def display_name
return username if orphaned?
"#{first_name} #{last_name}".strip.presence || email || username || "Unknown User"
end
unless user.active?
Honeybadger.notify("User #{slack_id} tried to sign into the backend while inactive")
return nil
def active? = active
def activate! = update!(active: true)
def deactivate! = update!(active: false)
def super_admin? = super_admin
def program_manager? = program_manager
def manual_document_verifier? = manual_document_verifier
def human_endorser? = human_endorser
def all_fields_access? = all_fields_access
def can_break_glass? = can_break_glass
# Returns a human-readable string of the user's roles
def pretty_roles
roles = []
roles << "Super Admin" if super_admin?
roles << "Program Manager" if program_manager?
roles << "Manual Document Verifier" if manual_document_verifier?
roles << "Human Endorser" if human_endorser?
roles << "All Fields Access" if all_fields_access?
roles.presence&.join(", ") || "None"
end
user.username ||= user_data.dig("user", "profile", "display_name_normalized")
user.username ||= user_data.dig("user", "profile", "real_name_normalized")
user.username ||= user_data.dig("user", "profile", "username")
user.icon_url = user_data.dig("user", "profile", "image_192") || user_data.dig("user", "profile", "image_72")
# Store the OAuth data
user.save!
user
end
def activate!
update!(active: true)
end
def deactivate!
update!(active: false)
end
def pretty_roles
return "Super admin" if super_admin?
roles = []
roles << "Program manager" if program_manager?
roles << "Document verifier" if manual_document_verifier?
roles << "Endorser" if human_endorser?
roles << "All fields" if all_fields_access?
roles.join(", ")
end
# Handle organized program IDs for forms
def organized_program_ids
organized_programs.pluck(:id)
end
def organized_program_ids=(program_ids)
@pending_program_ids = Array(program_ids).reject(&:blank?)
# If the user is already persisted, update associations immediately
if persisted?
update_organized_programs
end
end
# Callback to handle pending program IDs after save
after_save :update_organized_programs, if: -> { @pending_program_ids }
private
def update_organized_programs
return unless @pending_program_ids
# Clear existing organizer positions
organizer_positions.destroy_all
# Create new organizer positions for selected programs
@pending_program_ids.each do |program_id|
organizer_positions.create!(program_id: program_id)
# Returns an array of organized program IDs
def organized_program_ids
organized_programs.pluck(:id)
end
@pending_program_ids = nil
# Sets the organized programs by IDs
def organized_program_ids=(ids)
ids = Array(ids).map(&:to_i).uniq
current_ids = organized_program_ids
# Add new organizer positions
(ids - current_ids).each do |id|
organizer_positions.create(program_id: id)
end
# Remove organizer positions not in the new list
(current_ids - ids).each do |id|
organizer_positions.where(program_id: id).destroy_all
end
end
end
end

View file

@ -55,6 +55,16 @@ class Identity < ApplicationRecord
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
has_many :backup_codes, class_name: "Identity::BackupCode", dependent: :destroy
has_one :backend_user, class_name: "Backend::User", dependent: :destroy
def active_for_backend?
backend_user&.active?
end
has_many :documents, class_name: "Identity::Document", dependent: :destroy
has_many :verifications, class_name: "Verification", dependent: :destroy
has_many :document_verifications, class_name: "Verification::DocumentVerification", dependent: :destroy

View file

@ -1,4 +1,4 @@
<%= render Components::Window.new("Edit user: #{@user.username}", close_url: backend_users_path, max_width: 500) do %>
<%= render Components::Window.new("Edit user: #{@user.display_name}", close_url: backend_users_path, max_width: 500) do %>
<div class="padding">
<%= render Backend::Users::Form.new @user %>
</div>

View file

@ -1,10 +1,17 @@
<%= render Components::Window.new("Users", close_url: backend_root_path, max_width: 600) do %>
<%= render Components::Window.new("Users", close_url: backend_root_path, max_width: 700) do %>
<div class="padding">
<%= form_with url: backend_users_path, method: :get, class: "flex gap" do |f| %>
<%= f.text_field :search, placeholder: "Search by email or name...", value: params[:search], class: "input flex-1" %>
<%= f.submit "Search", class: "button" %>
<% end %>
</div>
<div class="padding">
<div class="list">
<table class="detailed">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Roles</th>
<th>Active?</th>
<th>View</th>
@ -14,8 +21,12 @@
<% @users.each do |user| %>
<tr>
<td>
<%= render user %>
<%= user.display_name %>
<% if user.orphaned? %>
<span class="badge badge-warning">⚠️ Not Linked</span>
<% end %>
</td>
<td><%= user.email || "—" %></td>
<td><%= user.pretty_roles %></td>
<td><%= render_checkbox(user.active?) %></td>
<td><%= link_to "go!", user, class: "link" %></td>
@ -27,7 +38,7 @@
</div>
<div class="padding flex justify-end">
<%= link_to new_backend_user_path, class: "button w-fit" do %>
+ create user
+ grant backend access
<% end %>
</div>
<% end %>

View file

@ -1,5 +1,62 @@
<%= render Components::Window.new("New User", close_url: backend_users_path, max_width: 500) do %>
<%= render Components::Window.new("Grant Backend Access", close_url: backend_users_path, max_width: 600) do %>
<div class="padding">
<%= render Backend::Users::Form.new @user %>
<h3>Search for an Identity</h3>
<%= form_with url: new_backend_user_path, method: :get, class: "flex gap" do |f| %>
<%= f.text_field :query, placeholder: "Search by email, name, or Slack ID...", value: params[:query], class: "input flex-1", autofocus: true %>
<%= f.submit "Search", class: "button" %>
<% end %>
</div>
<% if params[:query].present? %>
<div class="padding">
<% if @identities.any? %>
<table class="detailed">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Slack ID</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<% @identities.each do |identity| %>
<tr>
<td><%= "#{identity.first_name} #{identity.last_name}" %></td>
<td><%= identity.primary_email %></td>
<td><%= identity.slack_id || "—" %></td>
<td>
<%= link_to "Grant Access →", new_backend_user_path(identity_id: identity.id), class: "link" %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="text-muted">No identities found matching "<%= params[:query] %>"</p>
<% end %>
</div>
<% end %>
<% if params[:identity_id].present? %>
<% identity = Identity.find(params[:identity_id]) %>
<div class="padding">
<h3>Grant Access to <%= identity.first_name %> <%= identity.last_name %></h3>
<p><strong>Email:</strong> <%= identity.primary_email %></p>
<p><strong>Slack ID:</strong> <%= identity.slack_id || "Not linked" %></p>
<%= form_with url: backend_users_path, method: :post do |f| %>
<%= hidden_field_tag :identity_id, identity.id %>
<div class="padding-top">
<h4>Set Permissions</h4>
<%= render Backend::Users::PermissionsForm.new(Backend::User.new) %>
</div>
<div class="padding-top">
<%= f.submit "Grant Backend Access", class: "button" %>
</div>
<% end %>
</div>
<% end %>
<% end %>

View file

@ -1,23 +1,40 @@
<%= render Components::Window.new("User: #{@user.username}", close_url: backend_users_path) do %>
<%= render Components::Window.new("User: #{@user.display_name}", close_url: backend_users_path) do %>
<div class="padding">
<%= render Components::UserMention.new(@user) %>
<b>Roles: </b> <%= @user.pretty_roles %>
<br>
<b>Organized Programs: </b>
<% if @user.organized_programs.any? %>
<%= @user.organized_programs.map(&:name).join(", ") %>
<% if @user.orphaned? %>
<div class="alert alert-warning">
⚠️ <strong>Warning:</strong> This user is not linked to an Identity and cannot log in.
</div>
<% else %>
<em>None</em>
<div class="card">
<h4>Linked Identity</h4>
<p><strong>Name:</strong> <%= @user.first_name %> <%= @user.last_name %></p>
<p><strong>Email:</strong> <%= @user.email %></p>
<p><strong>Slack ID:</strong> <%= @user.slack_id || "Not linked" %></p>
</div>
<% end %>
<% super_admin_tool do %>
<%= link_to "edit this user", edit_backend_user_path(@user), class: "link" %>
<% if @user.active? %>
<%= button_to "deactivate this user", {action: :deactivate} %>
<i>(this will stop them from logging in)</i>
<% else %>
<%= button_to "activate this user", {action: :activate} %>
<i>(this will allow them to log in again)</i>
<div class="padding-top">
<b>Roles: </b> <%= @user.pretty_roles %>
<br>
<b>Organized Programs: </b>
<% if @user.organized_programs.any? %>
<%= @user.organized_programs.map(&:name).join(", ") %>
<% else %>
<em>None</em>
<% end %>
</div>
<% super_admin_tool do %>
<div class="padding-top">
<%= link_to "edit this user", edit_backend_user_path(@user), class: "link" %>
<% if @user.active? %>
<%= button_to "deactivate this user", {action: :deactivate} %>
<i>(this will stop them from logging in)</i>
<% else %>
<%= button_to "activate this user", {action: :activate} %>
<i>(this will allow them to log in again)</i>
<% end %>
</div>
<% end %>
</div>
<% end %>

View file

@ -1,37 +1,24 @@
# params.require(:backend_user).permit(:slack_id, :username, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin)
# params.require(:backend_user).permit(:username, :icon_url, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, :can_break_glass, organized_program_ids: [])
class Backend::Users::Form < ApplicationForm
def view_template(&)
div do
labeled field(:slack_id).input(disabled: !model.new_record?), "Slack ID: "
end
div do
labeled field(:username).input, "Display Name: "
end
b { "Roles: " }
div class: "grid gap align-center", style: "grid-template-columns: max-content auto;" do
check_box(field(:super_admin), "Allows this user access to all permissions<br/> (this includes managing other users)")
check_box(field(:program_manager), "This user can provision API keys and program tags.")
check_box(field(:human_endorser), "This user can mark identities as <br/>human-endorsed.")
check_box(field(:all_fields_access), "This user can view all fields on all identities.")
check_box(field(:manual_document_verifier), "This user can mark documents as<br/>manually verified.")
check_box(field(:can_break_glass), "This user can view ID docs after they've been reviewed.")
end
b { "Program Organizer Positions: " }
div class: "grid gap", style: "grid-template-columns: 1fr;" do
Program.all.each do |program|
is_organizer = model.organized_programs.include?(program)
div class: "flex-column" do
div class: "checkbox-row" do
check_box_tag("backend_user[organized_program_ids][]", program.id, is_organizer, id: "organized_program_#{program.id}")
label(for: "organized_program_#{program.id}") { program.name }
end
end
unless model.orphaned?
div class: "card margin-bottom" do
h4 { "Linked Identity" }
p { b { "Name: " }; text "#{model.first_name} #{model.last_name}" }
p { b { "Email: " }; text model.email }
p { b { "Slack ID: " }; text model.slack_id || "Not linked" }
end
end
submit model.new_record? ? "create!" : "save"
if model.orphaned?
div do
labeled field(:username).input, "Display Name: "
end
end
render Backend::Users::PermissionsForm.new(model)
submit "save"
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Backend::Users::PermissionsForm < ApplicationForm
def view_template(&)
b { "Roles: " }
div class: "grid gap align-center", style: "grid-template-columns: max-content auto;" do
check_box(field(:super_admin), "Allows this user access to all permissions<br/> (this includes managing other users)")
check_box(field(:program_manager), "This user can provision API keys and program tags.")
check_box(field(:human_endorser), "This user can mark identities as <br/>human-endorsed.")
check_box(field(:all_fields_access), "This user can view all fields on all identities.")
check_box(field(:manual_document_verifier), "This user can mark documents as<br/>manually verified.")
check_box(field(:can_break_glass), "This user can view ID docs after they've been reviewed.")
end
b { "Program Organizer Positions: " }
div class: "grid gap", style: "grid-template-columns: 1fr;" do
Program.all.each do |program|
is_organizer = model.organized_programs.include?(program)
div class: "flex-column" do
div class: "checkbox-row" do
check_box_tag("backend_user[organized_program_ids][]", program.id, is_organizer, id: "organized_program_#{program.id}")
label(for: "organized_program_#{program.id}") { program.name }
end
end
end
end
end
end

View file

@ -159,10 +159,13 @@
class SuperAdminConstraint
def self.matches?(request)
return false unless request.session[:user_id]
session_token = request.cookie_jar.encrypted[:session_token]
return false unless session_token
user = Backend::User.find_by(id: request.session[:user_id])
user&.super_admin?
session = IdentitySession.not_expired.find_by(session_token: session_token)
return false unless session&.identity
session.identity.backend_user&.super_admin?
end
end
@ -189,12 +192,6 @@ Rails.application.routes.draw do
get "login", to: "static_pages#login", as: :login
get "session_dump", to: "static_pages#session_dump", as: :session_dump unless Rails.env.production?
get "/auth/slack", to: "sessions#new", as: :slack_auth
get "/auth/slack/callback", to: "sessions#create"
if Rails.env.development?
post "/auth/slack/fake", to: "sessions#fake_slack_callback_for_dev", as: :fake_slack_callback_for_dev
end
resources :users do
member do

View file

@ -0,0 +1,22 @@
class LinkBackendUserToIdentity < ActiveRecord::Migration[8.0]
def up
add_reference :backend_users, :identity, foreign_key: true, null: true, index: false
Backend::User.reset_column_information
Backend::User.find_each do |user|
if user.slack_id.present?
identity = Identity.find_by(slack_id: user.slack_id)
if identity
user.update_column(:identity_id, identity.id)
end
end
end
add_index :backend_users, :identity_id
end
def down
remove_reference :backend_users, :identity
end
end

View file

@ -0,0 +1,6 @@
class RemoveSlackIdFromBackendUsers < ActiveRecord::Migration[8.0]
def change
remove_index :backend_users, :slack_id
remove_column :backend_users, :slack_id, :string
end
end

5
db/schema.rb generated
View file

@ -100,7 +100,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_02_173250) do
end
create_table "backend_users", force: :cascade do |t|
t.string "slack_id"
t.string "username"
t.string "icon_url"
t.boolean "super_admin"
@ -113,7 +112,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_02_173250) do
t.boolean "active"
t.string "credential_id"
t.boolean "can_break_glass"
t.index ["slack_id"], name: "index_backend_users_on_slack_id"
t.bigint "identity_id"
t.index ["identity_id"], name: "index_backend_users_on_identity_id"
end
create_table "break_glass_records", force: :cascade do |t|
@ -537,6 +537,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_02_173250) do
add_foreign_key "addresses", "identities"
add_foreign_key "backend_organizer_positions", "backend_users"
add_foreign_key "backend_organizer_positions", "oauth_applications", column: "program_id"
add_foreign_key "backend_users", "identities"
add_foreign_key "break_glass_records", "backend_users"
add_foreign_key "identities", "addresses", column: "primary_address_id"
add_foreign_key "identity_aadhaar_records", "identities"