diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 56edb7820e..18b1301e68 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,6 +40,7 @@ When changing a model or controller, check whether these related files need upda | Decorator | Decorator spec | | Mailer (add/remove) | Mailer spec, mailer preview (follow existing patterns) | | Add/remove model, concern, service, or gem | AGENTS.md | +| Add an association to `Person`/`User`, or a new model referencing them (`person_id`, `user_id`, `created_by_id`, `updated_by_id`, polymorphic `*able`) | Wire it into `PeopleRemover` (`app/services/people_remover.rb`) so a purge cascades/clears it (or deliberately blocks). `spec/services/people_remover_association_coverage_spec.rb` is the source of truth — it fails on new **associations** until they're handled and allowlisted there; it can't catch a new model that only *references* Person/User, so check that case by hand | ## Code Style diff --git a/AGENTS.md b/AGENTS.md index af9e50c08b..79506025ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ end - `WorkshopVariationFromIdeaService` — Variation creation from ideas - `TaggingSearchService` — Search and filter tagging data - `PersonFromUserService` — Create Person from User account +- `PeopleRemover` — Deletes a set of people and ALL their associated data, their linked User + the user's own data where relevant (real authored content is FK-restricted and blocks), plus the full PaperTrail/Ahoy audit graph; protects real/admin accounts (override with `force`). Used by the `data:remove_people` rake task and the admin-only "Delete person & all data" purge button on person edit (`purge_confirmation`/`purge` actions) - `BulkInviteService` — Bulk send welcome instructions and reset created_at for users - `FormBuilderService` — Builds configurable forms from composable sections with per-field visibility - `ModelDeduper` — Deduplication logic @@ -413,8 +414,9 @@ RuboCop linting on PRs and pushes to main. ## Rake Tasks -Located in `lib/tasks/` (4 files): +Located in `lib/tasks/` (9 files): - `dev.rake` — Development database seeding from XML/CSV - `rhino_migrator.rake` — Rich text editor migration - `attachment_report.rake` — Attachment reporting - `migrate_internal_id_to_filemaker_code.rake` — FileMaker code migration +- `remove_people.rake` (`data:remove_people`) — Removes fake form-submission people via `PeopleRemover` (dry run unless `CONFIRM=true`) diff --git a/CLAUDE.md b/CLAUDE.md index e28c34816e..9895483cdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ When changing a model or controller, check whether these related files need upda | Decorator | Decorator spec | | Mailer (add/remove) | Mailer spec, mailer preview (follow existing patterns) | | Add/remove model, concern, service, or gem | AGENTS.md | +| Add an association to `Person`/`User`, or a new model referencing them (`person_id`, `user_id`, `created_by_id`, `updated_by_id`, polymorphic `*able`) | Wire it into `PeopleRemover` (`app/services/people_remover.rb`) so a purge cascades/clears it (or deliberately blocks). `spec/services/people_remover_association_coverage_spec.rb` is the source of truth — it fails on new **associations** until they're handled and allowlisted there; it can't catch a new model that only *references* Person/User, so check that case by hand | ## Code Style diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 7df2931117..18b9d2d84c 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -1,6 +1,6 @@ class PeopleController < ApplicationController include AhoyTracking, TagAssignable - before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio ] + before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio purge_confirmation purge ] def index authorize! @@ -227,6 +227,42 @@ def destroy end end + # Admin-only interstitial: shows exactly what would be deleted for this person + # (and why they're protected, if so) before anything is removed. + def purge_confirmation + authorize! @person, to: :purge? + @force = params[:force] == "true" + # force: true so the inventory reflects the full graph that WOULD be deleted, + # even when the person looks real — the page always shows what's at stake. + preview = PeopleRemover.new(person_ids: [ @person.id ], force: true) + @protections = preview.protection_reasons(@person) + @counts = preview.counts + # Everything removed with the linked account — only relevant when overriding, + # since that's when the account itself (and its content) gets destroyed. + @account_content = @force ? preview.account_content : {} + @person = @person.decorate + end + + # Admin-only deletion of a person and their whole data + audit graph. Skips a + # person who looks real unless force=true overrides the protections (see + # PeopleRemover). + def purge + authorize! @person, to: :purge? + remover = PeopleRemover.new(person_ids: [ @person.id ], force: params[:force] == "true") + + if remover.call.include?(@person.id) + redirect_to people_path, status: :see_other, + notice: "#{@person.full_name} and all their data were deleted." + else + reasons = remover.skipped.find { |skip| skip.person.id == @person.id }&.reasons + redirect_to edit_person_path(@person), status: :see_other, + alert: "Couldn't delete — this person looks real (#{reasons&.join(', ')}). Use “Delete anyway” to override." + end + rescue ActiveRecord::InvalidForeignKey + redirect_to edit_person_path(@person), status: :see_other, + alert: "Couldn't delete #{@person.full_name} — the linked User Account is still referenced by records it doesn't own (e.g. a shared banner or event it created). Reassign those first." + end + def check_duplicates authorize! diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 1f12418ba9..59efe126f6 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -29,6 +29,14 @@ def destroy? admin? && record.persisted? && !has_associated_data? end + # Full purge of a person and their entire data graph. Bypasses the + # has_associated_data? guard on destroy? on purpose — purging exists precisely + # to remove people who have that data. The PeopleRemover service still + # refuses to touch anyone who looks real unless explicitly forced. + def purge? + admin? + end + def search? admin? end diff --git a/app/services/people_remover.rb b/app/services/people_remover.rb new file mode 100644 index 0000000000..7b0693b70c --- /dev/null +++ b/app/services/people_remover.rb @@ -0,0 +1,324 @@ +# Deletes a set of people along with ALL of their associated data, their linked +# User account and the user's own data (where relevant), and the audit trail for +# everything removed. Backs both the data:remove_people rake task and +# the admin-only "Delete person & all data" button on the person edit page. +# +# Person.destroy cascades everything a person owns — submissions, answers, +# affiliations, registrations, scholarships, addresses, contact methods, +# bookmarks, comments, sector/category tags, grants, event staffing. This also +# tears down what does NOT cascade off Person: payments (+ their refunds and +# allocations), bulk-payment notifications, Stripe Pay::Customer rows, and the +# PaperTrail + Ahoy audit trail for every record removed. +# +# The linked User and ALL of its own data are removed too: bookmarks, comments, +# user_forms, the user's notifications, Ahoy activity, AND everything it authored +# (workshops, workshop logs, reports, resources, stories, and the various ideas +# and variations). The purge preview lists all of this so the operator sees the +# full blast radius before confirming. +# +# People that look real are SKIPPED, never deleted: a grant donor, a spotlighted +# facilitator, or anyone with a User account — unless delete_users: true AND the +# account is clearly an unused auto-created one (never signed in, not a +# super_user, no Ahoy activity of its own, authored no other people). A real +# admin is always protected. +# +# force: true is a deliberate escape hatch that ignores ALL of the protections +# above and deletes anyway — the account and everything it authored. Records that +# merely *reference* the account without being owned by it (e.g. it created a +# shared banner or event) are FK-restricted; the transaction rolls back and the +# caller surfaces a clear error rather than orphaning or rewriting them. +# +# MAINTENANCE: the deletion graph is partly hardcoded (collected, audit_targets, +# authored_content_scopes, account_content). When an association is added to +# Person/User — or a model starts referencing them (person_id, user_id, +# created_by_id, updated_by_id, polymorphic *able) — wire it in here so purges +# keep cascading/clearing it instead of orphaning or blocking. +# spec/services/people_remover_association_coverage_spec.rb fails when a new +# Person/User association isn't accounted for. +class PeopleRemover + Skip = Data.define(:person, :reasons) + + # Only these models carry has_paper_trail. + VERSIONED_TYPES = %w[Payment Refund Allocation].freeze + + def initialize(person_ids: [], form_submission_ids: [], delete_users: false, force: false) + @person_ids = Array(person_ids).map(&:to_i).uniq + @form_submission_ids = Array(form_submission_ids).map(&:to_i).uniq + @delete_users = delete_users + @force = force + end + + # Why a person is protected, independent of delete_users/force — for showing + # the operator what they'd be overriding. Empty means the person is safe. + def protection_reasons(person) + reasons = [] + reasons << account_description(person.user) if person.user.present? + reasons << "is a spotlighted facilitator" if person.stories_as_spotlighted_facilitator.exists? + reasons << "is a grant donor" if person.grants.exists? + reasons + end + + # People that will be deleted (passed every safety check). + def deletable + @deletable ||= people.select { |person| reasons_for(person).empty? } + end + + # People that look real, with the reason(s) they were skipped. + def skipped + @skipped ||= people.filter_map do |person| + reasons = reasons_for(person) + Skip.new(person: person, reasons: reasons) if reasons.any? + end + end + + def missing_person_ids + target_person_ids - people.map(&:id) + end + + def missing_form_submission_ids + @form_submission_ids - FormSubmission.where(id: @form_submission_ids).pluck(:id) + end + + # Everything removed along with the destroyed User account(s) — its authored + # content plus the records that cascade with it — for the purge preview. Empty + # when no account is involved. + def account_content + user_ids = collected[:users].map(&:id) + return {} if user_ids.empty? + + counts = authored_content_scopes(user_ids).to_h { |label, relation| [ label, relation.count ] } + counts["Bookmarks"] = Bookmark.where(user_id: user_ids).count + counts["User forms"] = UserForm.where(user_id: user_ids).count + counts["Notifications"] = Notification.where(noticeable_type: "User", noticeable_id: user_ids).count + counts.select { |_, count| count.positive? } + end + + # Per-record-type counts for a dry-run preview. Does not mutate anything. + def counts + c = collected + { + people: deletable.size, + users: c[:users].size, + form_submissions: c[:submission_ids].size, + form_answers: c[:answer_ids].size, + affiliations: c[:affiliation_ids].size, + event_registrations: c[:registration_ids].size, + scholarships: c[:scholarship_ids].size, + payments: c[:payment_ids].size, + refunds: c[:refund_ids].size, + allocations: c[:allocation_ids].size, + notifications: c[:notification_ids].size, + pay_customers: c[:pay_customer_ids].size, + paper_trail_versions: version_count, + ahoy_events: ahoy_count + } + end + + # Deletes the graph in a single transaction. Returns the ids of the deleted + # people (empty when nothing was safe to delete). + def call + return [] if deletable.empty? + + # The destroys below buffer Ahoy "destroy" lifecycle events (the who/what/when + # deletion audit), but the buffer is flushed after the request — outside this + # transaction. Remember its size so a rollback can drop the events this purge + # added; otherwise the controller flushes phantom destroys for records that + # still exist. + buffered_lifecycle_events = Analytics::LifecycleBuffer.store.size + c = collected + user_ids = c[:users].map(&:id) + ActiveRecord::Base.transaction do + # Detach the user<->person link first. update_column bypasses the User + # validation that forbids nulling person_id and makes Person's has_one :user + # nullify a no-op — so we can delete the person before the user (next), even + # though the person references the user via created_by/updated_by. + c[:users].each { |user| user.update_column(:person_id, nil) } + deletable.each { |person| person.association(:user).reset } + + # Non-cascading person records, FK-safe order: allocations and refunds + # reference payments; payments reference submissions. + Allocation.where(id: c[:allocation_ids]).find_each(&:destroy!) + Refund.where(id: c[:refund_ids]).find_each(&:destroy!) + Payment.where(id: c[:payment_ids]).find_each(&:destroy!) + Notification.where(id: c[:notification_ids]).find_each(&:destroy!) + Pay::Customer.where(id: c[:pay_customer_ids]).find_each(&:destroy!) + + # Everything the account authored (FK-restricted, leaf → root) plus its own + # notifications — these don't cascade off the user and would block it. + authored_content_scopes(user_ids).each { |_, relation| relation.find_each(&:destroy!) } + Notification.where(noticeable_type: "User", noticeable_id: user_ids).delete_all + + # People BEFORE users: destroying a person removes its created_by/updated_by + # and affiliations (all FK to users), so the account is unreferenced by the + # time it's destroyed. + deletable.each(&:destroy!) + c[:users].each(&:destroy!) + + delete_audit_trail(c) + end + + deletable.map(&:id) + rescue + # Roll back the lifecycle events this purge buffered so a failed delete can't + # leave phantom Ahoy "destroy" records for records that still exist. + Analytics::LifecycleBuffer.store.slice!(buffered_lifecycle_events..) + raise + end + + private + + attr_reader :delete_users, :force + + def target_person_ids + @target_person_ids ||= begin + derived = FormSubmission.where(id: @form_submission_ids).distinct.pluck(:person_id) + (@person_ids + derived).uniq + end + end + + def people + @people ||= Person.where(id: target_person_ids).to_a + end + + # Content authored by the account(s), as [label, relation] in FK-safe delete + # order (leaf → root). created_by_id-owned with no dependent, so it must be + # destroyed explicitly before the account or its FKs block the deletion. + def authored_content_scopes(user_ids) + [ + [ "Comments", Comment.where(created_by_id: user_ids) ], + [ "Story ideas", StoryIdea.where(created_by_id: user_ids) ], + [ "Workshop ideas", WorkshopIdea.where(created_by_id: user_ids) ], + [ "Workshop variation ideas", WorkshopVariationIdea.where(created_by_id: user_ids) ], + [ "Workshop variations", WorkshopVariation.where(created_by_id: user_ids) ], + [ "Workshop logs", WorkshopLog.where(created_by_id: user_ids) ], + [ "Stories", Story.where(created_by_id: user_ids) ], + [ "Workshops", Workshop.where(created_by_id: user_ids) ], + [ "Reports", Report.where(created_by_id: user_ids) ], + [ "Resources", Resource.where(created_by_id: user_ids) ] + ] + end + + def reasons_for(person) + return [] if force + + reasons = [] + if person.user.present? && (real_user?(person.user) || !delete_users) + reasons << account_description(person.user) + end + reasons << "is a spotlighted facilitator" if person.stories_as_spotlighted_facilitator.exists? + reasons << "is a grant donor" if person.grants.exists? + reasons + end + + def account_description(user) + if real_user?(user) + "has an active/admin User account (signed in, super_user, or has its own activity)" + else + "has a User account" + end + end + + # A User is real (and must never be deleted) if it has ever signed in, is a + # super_user, has Ahoy activity of its own, or authored other people. This runs + # before any deletion, so cleaning the fake graph can't erase the signal. + def real_user?(user) + user.super_user? || + user.sign_in_count.to_i.positive? || + user.current_sign_in_at.present? || user.last_sign_in_at.present? || + Ahoy::Event.where(user_id: user.id).exists? || + Person.where(created_by_id: user.id).where.not(id: target_person_ids).exists? || + Person.where(updated_by_id: user.id).where.not(id: target_person_ids).exists? + end + + def collected + @collected ||= begin + person_ids = deletable.map(&:id) + submission_ids = FormSubmission.where(person_id: person_ids).pluck(:id) + registration_ids = EventRegistration.where(registrant_id: person_ids).pluck(:id) + scholarship_ids = Scholarship.where(recipient_id: person_ids).pluck(:id) + payment_ids = Payment.where(person_id: person_ids) + .or(Payment.where(form_submission_id: submission_ids)).pluck(:id) + { + person_ids: person_ids, + submission_ids: submission_ids, + registration_ids: registration_ids, + scholarship_ids: scholarship_ids, + affiliation_ids: Affiliation.where(person_id: person_ids).pluck(:id), + answer_ids: FormAnswer.where(form_submission_id: submission_ids).pluck(:id), + ero_ids: EventRegistrationOrganization.where(event_registration_id: registration_ids).pluck(:id), + address_ids: Address.where(addressable_type: "Person", addressable_id: person_ids).pluck(:id), + contact_method_ids: ContactMethod.where(contactable_type: "Person", contactable_id: person_ids).pluck(:id), + sectorable_item_ids: SectorableItem.where(sectorable_type: "Person", sectorable_id: person_ids).pluck(:id), + categorizable_item_ids: CategorizableItem.where(categorizable_type: "Person", categorizable_id: person_ids).pluck(:id), + payment_ids: payment_ids, + refund_ids: Refund.where(refundable_type: "Payment", refundable_id: payment_ids).pluck(:id), + allocation_ids: allocation_ids(payment_ids, scholarship_ids, registration_ids), + notification_ids: Notification.where(noticeable_type: "FormSubmission", noticeable_id: submission_ids).pluck(:id), + pay_customer_ids: Pay::Customer.where(owner_type: "Person", owner_id: person_ids).pluck(:id), + users: (delete_users || force) ? deletable.filter_map(&:user) : [] + } + end + end + + def allocation_ids(payment_ids, scholarship_ids, registration_ids) + Allocation + .where(source_type: "Payment", source_id: payment_ids) + .or(Allocation.where(source_type: "Scholarship", source_id: scholarship_ids)) + .or(Allocation.where(allocatable_type: "EventRegistration", allocatable_id: registration_ids)) + .pluck(:id) + end + + # resource_type => ids, covering every record this task removes — for the Ahoy + # event and PaperTrail version cleanup. + def audit_targets(c) + { + "Person" => c[:person_ids], + "FormSubmission" => c[:submission_ids], + "FormAnswer" => c[:answer_ids], + "Affiliation" => c[:affiliation_ids], + "EventRegistration" => c[:registration_ids], + "EventRegistrationOrganization" => c[:ero_ids], + "Scholarship" => c[:scholarship_ids], + "Address" => c[:address_ids], + "ContactMethod" => c[:contact_method_ids], + "SectorableItem" => c[:sectorable_item_ids], + "CategorizableItem" => c[:categorizable_item_ids], + "Payment" => c[:payment_ids], + "Refund" => c[:refund_ids], + "Allocation" => c[:allocation_ids], + "User" => c[:users].map(&:id) + }.reject { |_, ids| ids.empty? } + end + + def delete_audit_trail(c) + targets = audit_targets(c) + + # Includes the destroy-event versions PaperTrail just wrote for the + # versioned models. + targets.slice(*VERSIONED_TYPES).each do |type, ids| + PaperTrail::Version.where(item_type: type, item_id: ids).delete_all + end + targets.each do |type, ids| + Ahoy::Event.where(resource_type: type, resource_id: ids).delete_all + end + + user_ids = c[:users].map(&:id) + return if user_ids.empty? + + Ahoy::Event.where(user_id: user_ids).delete_all + Ahoy::Visit.where(user_id: user_ids).delete_all + end + + def version_count + audit_targets(collected).slice(*VERSIONED_TYPES) + .sum { |type, ids| PaperTrail::Version.where(item_type: type, item_id: ids).count } + end + + def ahoy_count + resource = audit_targets(collected) + .sum { |type, ids| Ahoy::Event.where(resource_type: type, resource_id: ids).count } + user_ids = collected[:users].map(&:id) + actor = user_ids.any? ? Ahoy::Event.where(user_id: user_ids).count : 0 + resource + actor + end +end diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 1df94ef0e4..e7c4a3eb69 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -2,6 +2,11 @@
+ <% if params[:admin] == "true" && allowed_to?(:purge?, @person) %> + <%= link_to "Delete person & all data", + purge_confirmation_person_path(@person), + class: "btn btn-danger-outline px-2 py-1" %> + <% end %> <% unless @person.user %>
No user! diff --git a/app/views/people/purge_confirmation.html.erb b/app/views/people/purge_confirmation.html.erb new file mode 100644 index 0000000000..8a92a8a67d --- /dev/null +++ b/app/views/people/purge_confirmation.html.erb @@ -0,0 +1,72 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "← Back to edit", edit_person_path(@person), class: "text-sm text-gray-500 hover:text-gray-700" %> +
+ +

+ Delete person & all data: <%= @person.name %> +

+ + <% if @protections.any? %> +
+

This person looks real:

+
    + <% @protections.each do |reason| %> +
  • <%= reason %>
  • + <% end %> +
+
+ <% end %> + + <% if @force %> +
+

⚠️ Override: deleting despite the protections above.

+

+ The linked User Account and everything it created (listed below) will be + permanently deleted too. This cannot be undone. +

+
+ + <% if @account_content.any? %> +

Also removed with the User Account

+
+ <% @account_content.each do |label, count| %> +
"> +
<%= label %>
+
"><%= count.zero? ? "—" : count %>
+
+ <% end %> +
+ <% end %> + <% elsif @protections.empty? %> +
+

⚠️ This permanently deletes the person and everything below. This cannot be undone.

+
+ <% end %> + +

What will be deleted

+
+ <% @counts.each do |label, count| %> +
"> +
<%= label.to_s.tr("_", " ").capitalize %>
+
"><%= count.zero? ? "—" : count %>
+
+ <% end %> +
+ +
+ <%= link_to "Cancel", edit_person_path(@person), class: "btn btn-secondary-outline" %> + <% if @protections.any? && !@force %> + <%= link_to "Preview full delete", + purge_confirmation_person_path(@person, force: true), + class: "btn btn-danger-outline" %> + <% else %> + <%= button_to "Permanently delete", + purge_person_path(@person, force: @force), + method: :delete, + class: "btn btn-danger", + form: { data: { turbo_confirm: "Permanently delete #{@person.name} and ALL the data listed? This cannot be undone." } } %> + <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index d5d8f257a9..9ee3605caf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -170,6 +170,8 @@ get :workshop_logs get :checkout get :bio + get :purge_confirmation + delete :purge end resources :comments, only: [ :index, :create, :update ] end diff --git a/lib/tasks/remove_people.rake b/lib/tasks/remove_people.rake new file mode 100644 index 0000000000..a5edbd8204 --- /dev/null +++ b/lib/tasks/remove_people.rake @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# Permanently remove people and ALL their associated data. Thin wrapper around +# PeopleRemover (app/services/people_remover.rb) — see it for the full deletion +# graph and the rules that protect real people. The original use case is cleaning +# up fake form-submission test data; FORM_SUBMISSION_IDS resolves those to their +# owning people. +# +# Usage (dry run — prints what WOULD be deleted, changes nothing): +# bin/rails data:remove_people PERSON_IDS=12,34,56 +# bin/rails data:remove_people FORM_SUBMISSION_IDS=78,90 +# +# To actually delete, add CONFIRM=true: +# bin/rails data:remove_people PERSON_IDS=12,34 CONFIRM=true +# +# DELETE_USERS=true also removes a person's unused auto-created account (never a +# super_user or anyone who has signed in / has activity). +# +# FORCE=true overrides ALL protections and deletes even people who look real +# (destroying any linked account). Use only for confirmed false positives. +# +# The deletion is attributed to an admin actor for the Ahoy audit trail: by +# default umberto.user@example.com, overridable with ACTOR_EMAIL=someone@example.com. +namespace :data do + desc "Permanently remove people and their full data + audit graph (dry run unless CONFIRM=true)" + task remove_people: :environment do + parse_ids = ->(raw) { raw.to_s.split(",").map(&:strip).reject(&:blank?).map(&:to_i).uniq } + + person_ids = parse_ids.call(ENV["PERSON_IDS"]) + submission_ids = parse_ids.call(ENV["FORM_SUBMISSION_IDS"]) + confirm = ENV["CONFIRM"] == "true" + + if person_ids.empty? && submission_ids.empty? + abort "Provide PERSON_IDS=1,2,3 and/or FORM_SUBMISSION_IDS=4,5,6" + end + + remover = PeopleRemover.new( + person_ids: person_ids, + form_submission_ids: submission_ids, + delete_users: ENV["DELETE_USERS"] == "true", + force: ENV["FORCE"] == "true" + ) + + puts "\n⚠️ WARNING: This permanently deletes each person and their ENTIRE data +" + puts " audit graph (and their User account where applicable). This cannot be" + puts " undone — verify every id against the dry-run output before re-running" + puts " with CONFIRM=true.\n" + + if remover.missing_form_submission_ids.any? + puts "Warning: FormSubmission ids not found: #{remover.missing_form_submission_ids.sort.join(', ')}" + end + if remover.missing_person_ids.any? + puts "Warning: Person ids not found: #{remover.missing_person_ids.sort.join(', ')}" + end + + if remover.skipped.any? + puts "\nSkipping #{remover.skipped.size} person(s) that look real:" + remover.skipped.each do |skip| + puts " ##{skip.person.id} #{skip.person.full_name_with_email} — #{skip.reasons.join(', ')}" + end + end + + if remover.deletable.empty? + puts "\nNothing to delete." + next + end + + puts "\n#{confirm ? 'DELETING' : 'DRY RUN — would delete'} for #{remover.deletable.size} person(s):" + remover.deletable.each { |person| puts " ##{person.id} #{person.full_name_with_email}" } + puts "\nRecords:" + remover.counts.each { |label, count| puts " #{label.to_s.tr('_', ' ').capitalize.ljust(21)}#{count}" } + + unless confirm + puts "\nDry run only. Re-run with CONFIRM=true to delete." + next + end + + # Attribute the deletion to an admin actor so the Ahoy "destroy" audit records + # who did it. Unlike the web flow there's no controller to flush the buffered + # lifecycle events, so we flush them here on success. + actor_email = ENV.fetch("ACTOR_EMAIL", "umberto.user@example.com") + actor = User.where(email: actor_email).last + puts "Warning: audit actor #{actor_email} not found — deletions won't be attributed." unless actor + + Current.user = actor + Current.source = "rake:data:remove_people" + begin + deleted = remover.call + flush_audit = -> { + events = Analytics::LifecycleBuffer.store + return if events.empty? + tracker = Ahoy::Tracker.new(user: actor) + events.each { |payload| tracker.track(payload[:name], payload[:properties]) } + events.clear + } + flush_audit.call + puts "\nDone. Deleted #{deleted.size} person(s) and their data + audit graph." + rescue ActiveRecord::InvalidForeignKey => e + abort "\nAborted (nothing deleted): a linked User Account owns content that " \ + "can't be orphaned (workshops, reports, stories, etc.). Reassign or " \ + "remove it first.\n#{e.message}" + ensure + Current.reset + end + end +end diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index c299741f0f..1efd3fb1a4 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -91,6 +91,20 @@ def policy_for(record: nil, user:) end end + describe "#purge?" do + context "with admin user" do + subject { policy_for(record: searchable_person, user: admin_user) } + + it { is_expected.to be_allowed_to(:purge?) } + end + + context "with regular user" do + subject { policy_for(record: searchable_person, user: regular_user) } + + it { is_expected.not_to be_allowed_to(:purge?) } + end + end + describe "#edit?" do context "with admin user" do subject { policy_for(record: searchable_person, user: admin_user) } diff --git a/spec/requests/people_purge_spec.rb b/spec/requests/people_purge_spec.rb new file mode 100644 index 0000000000..9a4b002db4 --- /dev/null +++ b/spec/requests/people_purge_spec.rb @@ -0,0 +1,129 @@ +require "rails_helper" + +RSpec.describe "People purge", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user, :with_person) } + let!(:person) { create(:person, user: nil) } + let!(:submission) { create(:form_submission, person: person) } + + describe "GET /people/:id/purge_confirmation (preview)" do + context "as an admin" do + before { sign_in admin } + + it "shows the inventory and a delete button for a fake person" do + get purge_confirmation_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("What will be deleted") + expect(response.body).to include("Permanently delete") + end + + it "shows the inventory and an override (not a direct delete) for a real person" do + real = create(:person) # factory gives them a User account + get purge_confirmation_person_path(real) + + expect(response.body).to include("looks real") + expect(response.body).to include("What will be deleted") + expect(response.body).to include("Preview full delete") + expect(response.body).not_to include("Permanently delete") + end + + it "shows the override warning, the account's content, and a delete button when force=true" do + real = create(:person) + create(:workshop, created_by: real.user) + + get purge_confirmation_person_path(real, force: true) + + expect(response.body).to include("Override") + expect(response.body).to include("Also removed with the User Account") + expect(response.body).to include("Workshops") + expect(response.body).to include("Permanently delete") + end + end + + context "as a regular user" do + before { sign_in regular_user } + + it "is forbidden" do + get purge_confirmation_person_path(person) + expect(response).not_to have_http_status(:ok) + end + end + end + + describe "DELETE /people/:id/purge" do + context "as an admin" do + before { sign_in admin } + + it "deletes the fake person and their data" do + delete purge_person_path(person) + + expect(response).to redirect_to(people_path) + expect(Person.exists?(person.id)).to be false + expect(FormSubmission.exists?(submission.id)).to be false + end + + it "skips a person who looks real and redirects back with an alert" do + real = create(:person) + delete purge_person_path(real) + + expect(response).to redirect_to(edit_person_path(real)) + expect(flash[:alert]).to be_present + expect(Person.exists?(real.id)).to be true + end + + it "deletes a person who looks real when force=true" do + real = create(:person) + real.user.update!(super_user: true) + + delete purge_person_path(real, force: true) + + expect(response).to redirect_to(people_path) + expect(Person.exists?(real.id)).to be false + end + + it "deletes the account's authored content too when force=true" do + real = create(:person) + workshop = create(:workshop, created_by: real.user) + + delete purge_person_path(real, force: true) + + expect(response).to redirect_to(people_path) + expect(Person.exists?(real.id)).to be false + expect(Workshop.exists?(workshop.id)).to be false + end + end + + context "as a regular user" do + before { sign_in regular_user } + + it "is forbidden and does not delete" do + delete purge_person_path(person) + + expect(response).not_to have_http_status(:ok) + expect(Person.exists?(person.id)).to be true + end + end + + context "as a visitor" do + it "redirects to sign in" do + delete purge_person_path(person) + + expect(response).to redirect_to(new_user_session_path) + expect(Person.exists?(person.id)).to be true + end + end + end + + describe "the edit-page button" do + before { sign_in admin } + + it "appears only when admin=true is in the query" do + get edit_person_path(person, admin: "true") + expect(response.body).to include("Delete person & all data") + + get edit_person_path(person) + expect(response.body).not_to include("Delete person & all data") + end + end +end diff --git a/spec/services/people_remover_association_coverage_spec.rb b/spec/services/people_remover_association_coverage_spec.rb new file mode 100644 index 0000000000..527098c547 --- /dev/null +++ b/spec/services/people_remover_association_coverage_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +# Drift guard: PeopleRemover hardcodes the person/user deletion graph, so adding +# an association to Person or User can silently leave a purge orphaning or +# blocking on the new data. This fails when a new association appears that isn't +# in the allowlist below — forcing a conscious decision to wire it into +# app/services/people_remover.rb (cascade it, tear it down explicitly, or make it +# a deliberate blocker) and then list it here. +RSpec.describe "PeopleRemover association coverage" do + # Every association currently accounted for. Keep alphabetized. + let(:person_associations) do + %w[ + addresses affiliations avatar_attachment avatar_blob bookmarks categories + categorizable_items comments communal_reports contact_methods created_by + event_registrations event_staffs events form_submissions grants organizations + pay_charges pay_customers pay_subscriptions payment_processor scholarships + sectorable_items sectors staffed_events stories_as_spotlighted_facilitator + updated_by user windows_types + ] + end + + let(:user_associations) do + %w[ + bookmarked_events bookmarked_resources bookmarked_video_recordings + bookmarked_workshops bookmarks comments created_by event_registrations events + favorite_event notifications organizations person reports resources + stories_as_creator story_ideas_as_creator updated_by user_form_form_fields + user_forms windows_types workshop_ideas_as_creator workshop_logs + workshop_variation_ideas_creator workshop_variations_as_creator workshops + ] + end + + it "accounts for every Person association" do + unhandled = Person.reflect_on_all_associations.map { |a| a.name.to_s } - person_associations + + expect(unhandled).to be_empty, + "New Person association(s) not accounted for: #{unhandled.join(', ')}.\n" \ + "Wire them into app/services/people_remover.rb (cascade, explicit teardown, " \ + "or a deliberate blocker), then add them to this allowlist." + end + + it "accounts for every User association" do + unhandled = User.reflect_on_all_associations.map { |a| a.name.to_s } - user_associations + + expect(unhandled).to be_empty, + "New User association(s) not accounted for: #{unhandled.join(', ')}.\n" \ + "Wire them into app/services/people_remover.rb (cascade, explicit teardown, " \ + "or a deliberate blocker), then add them to this allowlist." + end +end diff --git a/spec/services/people_remover_spec.rb b/spec/services/people_remover_spec.rb new file mode 100644 index 0000000000..cda6164efa --- /dev/null +++ b/spec/services/people_remover_spec.rb @@ -0,0 +1,297 @@ +require "rails_helper" + +RSpec.describe PeopleRemover do + # The :person factory associates a User by default; real fake-form people have + # none, so deletable candidates are built with user: nil. + + describe "#call deleting by person id" do + let!(:person) { create(:person, user: nil) } + let!(:other_person) { create(:person, user: nil) } + + let!(:submission) { create(:form_submission, person: person) } + let!(:form_field) { create(:form_field, form: submission.form) } + let!(:answer) { create(:form_answer, form_submission: submission, form_field: form_field) } + let!(:affiliation) { create(:affiliation, person: person) } + let!(:registration) { create(:event_registration, registrant: person) } + let!(:scholarship) { create(:scholarship, recipient: person) } + + # Records NOT cascaded by Person.destroy. + let!(:payment) { create(:payment, person: person, form_submission: submission) } + let!(:refund) { create(:refund, refundable: payment) } + let!(:scholarship_allocation) { create(:allocation, source: scholarship, allocatable: registration) } + let!(:bulk_notification) do + create(:notification, noticeable: submission, kind: :bulk_payment_confirmation, recipient_role: :person) + end + let!(:pay_customer) do + Pay::Customer.create!(owner: person, processor: "stripe", processor_id: "cus_fake_#{person.id}") + end + + # Audit trail for the removed records. + let!(:payment_version) { PaperTrail::Version.create!(item_type: "Payment", item_id: payment.id, event: "create") } + let!(:person_event) { create(:ahoy_event, name: "create.person", resource_type: "Person", resource_id: person.id) } + let!(:submission_event) do + create(:ahoy_event, name: "create.form_submission", resource_type: "FormSubmission", resource_id: submission.id) + end + + # An unrelated person's identical graph must survive untouched. + let!(:other_submission) { create(:form_submission, person: other_person) } + let!(:other_payment) { create(:payment, person: other_person) } + let!(:other_event) { create(:ahoy_event, name: "create.person", resource_type: "Person", resource_id: other_person.id) } + + def remove(**opts) + described_class.new(person_ids: [ person.id ], **opts).call + end + + it "returns the deleted person ids" do + expect(remove).to eq [ person.id ] + end + + it "destroys the person and the full cascaded graph" do + remove + + expect(Person.exists?(person.id)).to be false + expect(FormSubmission.exists?(submission.id)).to be false + expect(FormAnswer.exists?(answer.id)).to be false + expect(Affiliation.exists?(affiliation.id)).to be false + expect(EventRegistration.exists?(registration.id)).to be false + expect(Scholarship.exists?(scholarship.id)).to be false + end + + it "destroys records not cascaded by Person.destroy" do + remove + + expect(Payment.exists?(payment.id)).to be false + expect(Refund.exists?(refund.id)).to be false + expect(Allocation.exists?(scholarship_allocation.id)).to be false + expect(Notification.exists?(bulk_notification.id)).to be false + expect(Pay::Customer.exists?(pay_customer.id)).to be false + end + + it "deletes the PaperTrail and Ahoy audit trail for the removed records" do + remove + + expect(PaperTrail::Version.exists?(payment_version.id)).to be false + expect(Ahoy::Event.exists?(person_event.id)).to be false + expect(Ahoy::Event.exists?(submission_event.id)).to be false + end + + it "leaves unrelated people and their data untouched" do + remove + + expect(Person.exists?(other_person.id)).to be true + expect(FormSubmission.exists?(other_submission.id)).to be true + expect(Payment.exists?(other_payment.id)).to be true + expect(Ahoy::Event.exists?(other_event.id)).to be true + end + end + + describe "#call resolving by form submission id" do + let!(:person) { create(:person, user: nil) } + let!(:submission) { create(:form_submission, person: person) } + + it "resolves the owning person and deletes them" do + described_class.new(form_submission_ids: [ submission.id ]).call + + expect(Person.exists?(person.id)).to be false + expect(FormSubmission.exists?(submission.id)).to be false + end + end + + describe "protecting real-looking people" do + it "skips (does not delete) a person with a linked user account" do + person = create(:person) + create(:form_submission, person: person) + + remover = described_class.new(person_ids: [ person.id ]) + + expect(remover.call).to be_empty + expect(Person.exists?(person.id)).to be true + expect(remover.skipped.map { |s| s.person.id }).to include(person.id) + end + + it "skips a person who is a grant donor" do + person = create(:person, user: nil) + create(:grant, :donated_by_person, donor: person) + + described_class.new(person_ids: [ person.id ]).call + + expect(Person.exists?(person.id)).to be true + end + end + + describe "delete_users" do + it "destroys an unused auto-created account along with the person" do + person = create(:person) + user = person.user # factory default: not super_user, never signed in + + described_class.new(person_ids: [ person.id ], delete_users: true).call + + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + end + + it "still protects a super_user" do + person = create(:person) + person.user.update!(super_user: true) + + described_class.new(person_ids: [ person.id ], delete_users: true).call + + expect(Person.exists?(person.id)).to be true + end + + it "still protects a user who has signed in" do + person = create(:person) + person.user.update!(sign_in_count: 3, current_sign_in_at: Time.current) + + described_class.new(person_ids: [ person.id ], delete_users: true).call + + expect(Person.exists?(person.id)).to be true + end + + it "protects a user with Ahoy activity of its own" do + person = create(:person) + create(:ahoy_event, user: person.user, name: "view.workshop") + + described_class.new(person_ids: [ person.id ], delete_users: true).call + + expect(Person.exists?(person.id)).to be true + end + end + + describe "force: true (override)" do + it "deletes a person with an active/admin account and destroys the account" do + person = create(:person) + person.user.update!(super_user: true) + user = person.user + + described_class.new(person_ids: [ person.id ], force: true).call + + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + end + + it "deletes a grant donor that would otherwise be protected" do + person = create(:person, user: nil) + create(:grant, :donated_by_person, donor: person) + + described_class.new(person_ids: [ person.id ], force: true).call + + expect(Person.exists?(person.id)).to be false + end + + it "removes the user's own notifications when the account is destroyed" do + person = create(:person) + user = person.user + notification = create(:notification, noticeable: user) + + described_class.new(person_ids: [ person.id ], force: true).call + + expect(User.exists?(user.id)).to be false + expect(Notification.exists?(notification.id)).to be false + end + end + + describe "#protection_reasons" do + it "lists why a person looks real, regardless of force" do + person = create(:person) # factory gives a User account + create(:grant, :donated_by_person, donor: person) + + reasons = described_class.new(person_ids: [ person.id ], force: true).protection_reasons(person) + + expect(reasons).to include(a_string_matching(/User account/)) + expect(reasons).to include("is a grant donor") + end + + it "is empty for a plain fake person" do + person = create(:person, user: nil) + + expect(described_class.new(person_ids: [ person.id ]).protection_reasons(person)).to be_empty + end + end + + describe "#account_content (force)" do + it "lists the account's authored content and the records removed with it" do + person = create(:person) + user = person.user + create(:workshop, created_by: user) + create(:story, created_by: user) + create(:bookmark, user: user) + + content = described_class.new(person_ids: [ person.id ], force: true).account_content + + expect(content["Workshops"]).to eq 1 + expect(content["Stories"]).to eq 1 + expect(content["Bookmarks"]).to eq 1 + expect(content).not_to have_key("Reports") + end + + it "is empty when there is no linked account" do + person = create(:person, user: nil) + + expect(described_class.new(person_ids: [ person.id ], force: true).account_content).to be_empty + end + end + + describe "force delete when the person's own audit columns point to its user" do + it "deletes despite people.created_by_id/updated_by_id referencing the account" do + person = create(:person) + user = person.user + person.update_columns(created_by_id: user.id, updated_by_id: user.id) + + described_class.new(person_ids: [ person.id ], force: true).call + + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + end + end + + describe "lifecycle (Ahoy) events on a rolled-back purge" do + before { Current.user = create(:user, :admin) } + after { Current.reset } + + it "discards the destroy events the purge buffered so no phantom audit is left" do + person = create(:person) + create(:banner, created_by: person.user) # FK-restricted ref the purge can't remove → rollback + + Analytics::LifecycleBuffer.store.clear + Analytics::LifecycleBuffer.push({ name: "sentinel", properties: {} }) + + expect do + described_class.new(person_ids: [ person.id ], force: true).call + end.to raise_error(ActiveRecord::InvalidForeignKey) + + expect(Analytics::LifecycleBuffer.store).to eq([ { name: "sentinel", properties: {} } ]) + ensure + Analytics::LifecycleBuffer.store.clear + end + end + + describe "force delete of an account that authored content" do + it "deletes the person, the account, and everything it authored" do + person = create(:person) + user = person.user + workshop = create(:workshop, created_by: user) + story = create(:story, created_by: user) + + described_class.new(person_ids: [ person.id ], force: true).call + + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + expect(Workshop.exists?(workshop.id)).to be false + expect(Story.exists?(story.id)).to be false + end + end + + describe "#counts" do + it "reports per-record-type counts without mutating anything" do + person = create(:person, user: nil) + create(:form_submission, person: person) + + remover = described_class.new(person_ids: [ person.id ]) + + expect(remover.counts[:people]).to eq 1 + expect(remover.counts[:form_submissions]).to eq 1 + expect(Person.exists?(person.id)).to be true + end + end +end diff --git a/spec/tasks/remove_people_spec.rb b/spec/tasks/remove_people_spec.rb new file mode 100644 index 0000000000..33f1c78080 --- /dev/null +++ b/spec/tasks/remove_people_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" +require "rake" + +# Behaviour is covered in spec/services/people_remover_spec.rb. These specs just +# confirm the rake task wires ENV → PeopleRemover correctly. +RSpec.describe "data:remove_people" do + before(:all) do + Rails.application.load_tasks unless Rake::Task.task_defined?("data:remove_people") + end + + before { Rake::Task["data:remove_people"].reenable } + + def run_task(env = {}) + keys = %w[PERSON_IDS FORM_SUBMISSION_IDS CONFIRM DELETE_USERS FORCE] + saved = keys.index_with { |k| ENV[k] } + keys.each { |k| ENV.delete(k) } + env.each { |k, v| ENV[k.to_s] = v.to_s } + suppress_output { Rake::Task["data:remove_people"].invoke } + ensure + Rake::Task["data:remove_people"].reenable + keys.each { |k| ENV[k] = saved[k] } + end + + def suppress_output + original_stdout = $stdout + $stdout = StringIO.new + yield + ensure + $stdout = original_stdout + end + + it "aborts when no ids are given" do + expect { run_task }.to raise_error(SystemExit) + end + + it "deletes nothing without CONFIRM (dry run)" do + person = create(:person, user: nil) + create(:form_submission, person: person) + + expect { run_task(PERSON_IDS: person.id) }.not_to change(Person, :count) + expect(Person.exists?(person.id)).to be true + end + + it "deletes the targeted person with CONFIRM=true" do + person = create(:person, user: nil) + create(:form_submission, person: person) + + run_task(PERSON_IDS: person.id, CONFIRM: true) + + expect(Person.exists?(person.id)).to be false + end + + it "attributes the deletion audit to the admin actor" do + actor = create(:user, :admin, email: "umberto.user@example.com") + person = create(:person, user: nil) + + expect { run_task(PERSON_IDS: person.id, CONFIRM: true) } + .to change { Ahoy::Event.where(name: "destroy.person").count }.by(1) + + expect(Ahoy::Event.where(name: "destroy.person").last.user_id).to eq(actor.id) + ensure + Current.reset + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 279b99147f..d9b32da5dd 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -149,6 +149,7 @@ "app/views/organizations/new.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/new.html.erb" => "admin-only bg-blue-100", "app/views/people/new.html.erb" => "admin-only bg-blue-100", + "app/views/people/purge_confirmation.html.erb" => "admin-only bg-blue-100", "app/views/quotes/new.html.erb" => "admin-only bg-blue-100", "app/views/resources/new.html.erb" => "admin-only bg-blue-100", "app/views/sectors/new.html.erb" => "admin-only bg-blue-100",