From ca58cc3becf0eb5c545287b992be4d83870a6f29 Mon Sep 17 00:00:00 2001 From: nora <163450896+24c02@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:45:07 -0500 Subject: [PATCH] [Backend] Backend::User delenda est. (#66) kill me --- .../backend/application_controller.rb | 28 ++- app/controllers/backend/no_auth_controller.rb | 2 +- .../backend/sessions_controller.rb | 62 ------- app/controllers/backend/users_controller.rb | 35 +++- app/models/backend/user.rb | 171 ++++++------------ app/models/identity.rb | 10 + app/views/backend/users/edit.html.erb | 2 +- app/views/backend/users/index.html.erb | 17 +- app/views/backend/users/new.html.erb | 61 ++++++- app/views/backend/users/show.html.erb | 49 +++-- app/views/forms/backend/users/form.rb | 45 ++--- .../forms/backend/users/permissions_form.rb | 29 +++ config/routes.rb | 15 +- ...120195531_link_backend_user_to_identity.rb | 22 +++ ...2350_remove_slack_id_from_backend_users.rb | 6 + db/schema.rb | 5 +- 16 files changed, 297 insertions(+), 262 deletions(-) delete mode 100644 app/controllers/backend/sessions_controller.rb create mode 100644 app/views/forms/backend/users/permissions_form.rb create mode 100644 db/migrate/20251120195531_link_backend_user_to_identity.rb create mode 100644 db/migrate/20251120212350_remove_slack_id_from_backend_users.rb diff --git a/app/controllers/backend/application_controller.rb b/app/controllers/backend/application_controller.rb index 6a00c06..9f47877 100644 --- a/app/controllers/backend/application_controller.rb +++ b/app/controllers/backend/application_controller.rb @@ -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 diff --git a/app/controllers/backend/no_auth_controller.rb b/app/controllers/backend/no_auth_controller.rb index d42631d..eaee367 100644 --- a/app/controllers/backend/no_auth_controller.rb +++ b/app/controllers/backend/no_auth_controller.rb @@ -1,5 +1,5 @@ module Backend - class NoAuthController < ApplicationController + class NoAuthController < Backend::ApplicationController skip_before_action :authenticate_user! end end diff --git a/app/controllers/backend/sessions_controller.rb b/app/controllers/backend/sessions_controller.rb deleted file mode 100644 index b36b9e4..0000000 --- a/app/controllers/backend/sessions_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/backend/users_controller.rb b/app/controllers/backend/users_controller.rb index 2f58a55..ba7fbd1 100644 --- a/app/controllers/backend/users_controller.rb +++ b/app/controllers/backend/users_controller.rb @@ -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 diff --git a/app/models/backend/user.rb b/app/models/backend/user.rb index ae6ce93..e1b4aa4 100644 --- a/app/models/backend/user.rb +++ b/app/models/backend/user.rb @@ -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 diff --git a/app/models/identity.rb b/app/models/identity.rb index 90ca20d..61ef71d 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -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 diff --git a/app/views/backend/users/edit.html.erb b/app/views/backend/users/edit.html.erb index 7b290ea..d1ba537 100644 --- a/app/views/backend/users/edit.html.erb +++ b/app/views/backend/users/edit.html.erb @@ -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 %>
| User | +Roles | Active? | View | @@ -14,8 +21,12 @@ <% @users.each do |user| %>|||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| - <%= render user %> + <%= user.display_name %> + <% if user.orphaned? %> + ⚠️ Not Linked + <% end %> | +<%= user.email || "—" %> | <%= user.pretty_roles %> | <%= render_checkbox(user.active?) %> | <%= link_to "go!", user, class: "link" %> | @@ -27,7 +38,7 @@
| Name | +Slack ID | +Action | +|
|---|---|---|---|
| <%= "#{identity.first_name} #{identity.last_name}" %> | +<%= identity.primary_email %> | +<%= identity.slack_id || "—" %> | ++ <%= link_to "Grant Access →", new_backend_user_path(identity_id: identity.id), class: "link" %> + | +
No identities found matching "<%= params[:query] %>"
+ <% end %> +Email: <%= identity.primary_email %>
+Slack ID: <%= identity.slack_id || "Not linked" %>
+ + <%= form_with url: backend_users_path, method: :post do |f| %> + <%= hidden_field_tag :identity_id, identity.id %> + +Name: <%= @user.first_name %> <%= @user.last_name %>
+Email: <%= @user.email %>
+Slack ID: <%= @user.slack_id || "Not linked" %>
+