From 8e3a4c249a0508f538d8f33d0048d6925164ffae Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:47:31 -0400 Subject: [PATCH] hammer hammer --- .../backend/identities_controller.rb | 61 +++++++++++++++++++ app/controllers/backend/users_controller.rb | 2 +- app/helpers/backend/application_helper.rb | 5 ++ app/helpers/sessions_helper.rb | 2 +- app/models/backend/user.rb | 2 + app/models/identity.rb | 1 + app/policies/identity_policy.rb | 5 +- app/services/scim_service.rb | 31 ++++++++++ app/views/backend/identities/show.html.erb | 53 ++++++++++++++++ .../backend/shared/_permissions_form.html.erb | 1 + .../public_activity/identity/_ban.html.erb | 3 + .../public_activity/identity/_unban.html.erb | 3 + config/routes.rb | 2 + ...0827164018_add_can_ban_to_backend_users.rb | 7 +++ 14 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 app/views/public_activity/identity/_ban.html.erb create mode 100644 app/views/public_activity/identity/_unban.html.erb create mode 100644 db/migrate/20260827164018_add_can_ban_to_backend_users.rb diff --git a/app/controllers/backend/identities_controller.rb b/app/controllers/backend/identities_controller.rb index 87a1787f..d465bfc6 100644 --- a/app/controllers/backend/identities_controller.rb +++ b/app/controllers/backend/identities_controller.rb @@ -272,6 +272,67 @@ def flip redirect_to backend_identity_path(@identity) end + def ban + authorize @identity + + if @identity == current_user&.identity + flash[:alert] = "You can't ban your own account." + return redirect_to backend_identity_path(@identity) + end + + if params[:reason].blank? + flash[:alert] = "Reason is required to ban an account." + return redirect_to backend_identity_path(@identity) + end + + if params[:confirm_email] != @identity.primary_email + flash[:alert] = "Email confirmation did not match." + return redirect_to backend_identity_path(@identity) + end + + @identity.lock_account! + activity_params = { reason: params[:reason] } + + if params[:deactivate_slack] == "1" && @identity.slack_id.present? + result = SCIMService.deactivate_user(slack_id: @identity.slack_id) + activity_params[:slack_deactivated] = result[:success] + + if result[:success] + @identity.update!(disallow_slack: true) + flash[:notice] = "Account locked and Slack deactivated." + else + activity_params[:slack_error] = result[:error] + flash[:warning] = "Account locked, but Slack deactivation failed: #{result[:error]}" + end + else + flash[:notice] = "Account locked." + end + + @identity.create_activity( + :ban, + owner: current_user, + recipient: @identity, + parameters: activity_params + ) + + redirect_to backend_identity_path(@identity) + end + + def unban + authorize @identity + + @identity.unlock_account! + @identity.update!(disallow_slack: false) if @identity.disallow_slack? + @identity.create_activity( + :unban, + owner: current_user, + recipient: @identity, + ) + + flash[:notice] = "Account unlocked." + redirect_to backend_identity_path(@identity) + end + def reset_persona_attempts authorize @identity diff --git a/app/controllers/backend/users_controller.rb b/app/controllers/backend/users_controller.rb index 0c3d6ecc..7111e801 100644 --- a/app/controllers/backend/users_controller.rb +++ b/app/controllers/backend/users_controller.rb @@ -100,6 +100,6 @@ def set_user @user = User.find(params[:id]) end - 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: []) + def user_params = params.require(:backend_user).permit(:username, :icon_url, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, :can_ban, organized_program_ids: []) end end diff --git a/app/helpers/backend/application_helper.rb b/app/helpers/backend/application_helper.rb index 9df59a88..6f4ce2f2 100644 --- a/app/helpers/backend/application_helper.rb +++ b/app/helpers/backend/application_helper.rb @@ -20,6 +20,11 @@ def deletion_tool(class_name: "", element: "div", **options, &block) concat content_tag(element, class: "deletion-tool #{class_name}", **options, &block) end + def ban_tool(class_name: "", element: "div", **options, &block) + return unless current_user&.can_ban? || current_user&.super_admin? + concat content_tag(element, class: "ban-tool #{class_name}", **options, &block) + end + def program_manager_tool(class_name: "", element: "div", **options, &block) return unless current_user&.program_manager? || current_user&.super_admin? concat content_tag(element, class: "program-manager-tool #{class_name}", **options, &block) diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index 5999ad27..356017e5 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -4,7 +4,7 @@ module SessionsHelper class AccountLockedError < StandardError; end def sign_in(identity:, fingerprint_info: {}, impersonate: false) - raise(AccountLockedError, "Your HCB account has been locked.") if identity.locked? + raise(AccountLockedError, "Your account has been locked.") if identity.locked? # Preserve fingerprint info from session if not passed fingerprint_info = session[:fingerprint_info] if fingerprint_info.blank? && session[:fingerprint_info].present? diff --git a/app/models/backend/user.rb b/app/models/backend/user.rb index 71f9a3b0..f37b9a35 100644 --- a/app/models/backend/user.rb +++ b/app/models/backend/user.rb @@ -41,6 +41,7 @@ def human_endorser? = human_endorser def all_fields_access? = all_fields_access def can_break_glass? = can_break_glass def can_process_deletions? = can_process_deletions + def can_ban? = can_ban # Returns a human-readable string of the user's roles def pretty_roles @@ -50,6 +51,7 @@ def pretty_roles roles << "Manual Document Verifier" if manual_document_verifier? roles << "Human Endorser" if human_endorser? roles << "All Fields Access" if all_fields_access? + roles << "Ban" if can_ban? roles.presence&.join(", ") || "None" end diff --git a/app/models/identity.rb b/app/models/identity.rb index 568b245f..3aeb769e 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -341,6 +341,7 @@ def unlock_account! = update!(locked_at: nil) def lock_account! update!(locked_at: Time.current) sessions.update_all(expires_at: Time.current) + all_access_tokens.update_all(revoked_at: Time.current) end def self.calculate_age(birthday) diff --git a/app/policies/identity_policy.rb b/app/policies/identity_policy.rb index d38f24f2..d56ba76f 100644 --- a/app/policies/identity_policy.rb +++ b/app/policies/identity_policy.rb @@ -10,13 +10,16 @@ def update? = user.present? && (user.can_break_glass? || user.super_admin?) alias_method :promote_to_full_user?, :update? alias_method :clear_slack_photo?, :update? + def ban? = user.present? && (user.can_ban? || user.super_admin?) + alias_method :unban?, :ban? + def simulate_onboarding? = user&.super_admin? def flip? = user&.super_admin? alias_method :reset_persona_attempts?, :simulate_onboarding? class Scope < ApplicationPolicy::Scope def resolve - if user.super_admin? || user.manual_document_verifier? || user.all_fields_access? + if user.super_admin? || user.manual_document_verifier? || user.all_fields_access? || user.can_ban? scope.all elsif user.organized_programs.any? program_ids = user.organized_programs.pluck(:id) diff --git a/app/services/scim_service.rb b/app/services/scim_service.rb index f97dad65..d2e76d9a 100644 --- a/app/services/scim_service.rb +++ b/app/services/scim_service.rb @@ -243,6 +243,37 @@ def clear_profile_photo(slack_id:) { success: false, error: e.message } end + def deactivate_user(slack_id:) + if Rails.env.staging? + Rails.logger.info "Skipping Slack deactivation in staging for #{slack_id}" + return { success: true } + end + + response = client.patch("Users/#{slack_id}", { + schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + Operations: [ + { op: "replace", path: "active", value: false } + ] + }) + + if response.success? + { success: true } + else + error_msg = if response.body.is_a?(Hash) + response.body.dig("Errors", 0, "description") || + response.body["detail"] || + response.body["message"] || + response.body["error"] + end + error_msg ||= "Unknown error (Status #{response.status})" + { success: false, error: error_msg } + end + rescue => e + Rails.logger.error "Error deactivating Slack user #{slack_id}: #{e.message}" + Sentry.capture_exception(e, tags: { component: "slack", operation: "scim_deactivate_user" }) + { success: false, error: e.message } + end + def find_existing_user_by_email(email) return nil if email.include?('"') diff --git a/app/views/backend/identities/show.html.erb b/app/views/backend/identities/show.html.erb index 1515992a..3ff68603 100644 --- a/app/views/backend/identities/show.html.erb +++ b/app/views/backend/identities/show.html.erb @@ -339,6 +339,59 @@ <% end %> + <%# === Ban / Unlock === %> + <% ban_tool do %> +
+ <% if @identity.locked? %> + + + this account is locked + <%= button_to "unlock account", unban_backend_identity_path(@identity), method: :post, data: { confirm: "unlock #{@identity.first_name}'s account?" } %> + + <% if @identity.disallow_slack? || @identity.slack_id.present? %> + if their Slack was deactivated, you can reprovision it after unlocking. + <% end %> + + <% else %> +
+ ban tools + + <%= render Components::Backend::Banner.new(kind: :warning) do %> + this will lock the account, expire all sessions, and prevent login. + <% end %> + <%= form_with url: ban_backend_identity_path(@identity), method: :post, local: true do %> + + + + <% if @identity.slack_id.present? %> + + <% end %> + + + + + + + + <% end %> + +
+ <% end %> + <% end %> + <%# === Simulate onboarding === %> <%= render "backend/identities/simulate_onboarding", identity: @identity %> diff --git a/app/views/backend/shared/_permissions_form.html.erb b/app/views/backend/shared/_permissions_form.html.erb index ba5a4f46..952c35cc 100644 --- a/app/views/backend/shared/_permissions_form.html.erb +++ b/app/views/backend/shared/_permissions_form.html.erb @@ -7,6 +7,7 @@ +
diff --git a/app/views/public_activity/identity/_ban.html.erb b/app/views/public_activity/identity/_ban.html.erb new file mode 100644 index 00000000..b15f4a68 --- /dev/null +++ b/app/views/public_activity/identity/_ban.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + locked <%= activity.recipient&.first_name || "user" %>'s account.<% if activity.parameters[:slack_deactivated] %> Slack account deactivated.<% end %><% if activity.parameters[:reason].present? %> — "<%= activity.parameters[:reason] %>"<% end %> +<% end %> diff --git a/app/views/public_activity/identity/_unban.html.erb b/app/views/public_activity/identity/_unban.html.erb new file mode 100644 index 00000000..7da2c917 --- /dev/null +++ b/app/views/public_activity/identity/_unban.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + unlocked <%= activity.recipient&.first_name || "user" %>'s account. +<% end %> diff --git a/config/routes.rb b/config/routes.rb index fda93d23..84c50ff3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -236,6 +236,8 @@ def self.matches?(request) post :simulate_onboarding post :flip post :reset_persona_attempts + post :ban + post :unban end resources :addresses, only: [ :new, :create, :edit, :update, :destroy ], controller: "identity_addresses" end diff --git a/db/migrate/20260827164018_add_can_ban_to_backend_users.rb b/db/migrate/20260827164018_add_can_ban_to_backend_users.rb new file mode 100644 index 00000000..68c6d124 --- /dev/null +++ b/db/migrate/20260827164018_add_can_ban_to_backend_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddCanBanToBackendUsers < ActiveRecord::Migration[8.0] + def change + add_column :backend_users, :can_ban, :boolean, default: false, null: false + end +end