From fcd7f8dc53af6b34e3ca38ddc6bd31da7b2281f4 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 13 Oct 2026 08:59:58 -0400 Subject: [PATCH 1/4] Add Hack Club privacy hub delete and export endpoints --- app/controllers/privacy_controller.rb | 59 +++++ app/models/marksmith/renderer.rb | 36 +-- app/models/privacy_token_use.rb | 24 ++ app/services/privacy_deletion.rb | 244 ++++++++++++++++++ app/services/privacy_token.rb | 87 +++++++ config/recurring.yml | 5 + config/routes.rb | 2 + ...0261013120605_create_privacy_token_uses.rb | 10 + db/schema.rb | 9 +- docs/privacy.md | 94 +++++++ 10 files changed, 553 insertions(+), 17 deletions(-) create mode 100644 app/controllers/privacy_controller.rb create mode 100644 app/models/privacy_token_use.rb create mode 100644 app/services/privacy_deletion.rb create mode 100644 app/services/privacy_token.rb create mode 100644 db/migrate/20261013120605_create_privacy_token_uses.rb create mode 100644 docs/privacy.md diff --git a/app/controllers/privacy_controller.rb b/app/controllers/privacy_controller.rb new file mode 100644 index 00000000..bd0944e1 --- /dev/null +++ b/app/controllers/privacy_controller.rb @@ -0,0 +1,59 @@ +class PrivacyController < ActionController::API + rescue_from(StandardError) { |error| render_error :internal_error, :internal_server_error, error_class: error.class.name } + rescue_from(PrivacyToken::Invalid) { render_error :invalid_token, :unauthorized } + rescue_from(PrivacyToken::ActionMismatch) { render_error :action_mismatch, :bad_request } + rescue_from(PrivacyToken::Replayed) { render_error :replayed, :conflict } + rescue_from(PrivacyToken::Unavailable) { render_error :jwks_unavailable, :service_unavailable } + rescue_from(PrivacyDeletion::ManualReviewRequired) { render_error :manual_review_required, :conflict } + rescue_from(PrivacyDeletion::ExternalFailure) { render_error :storage_unavailable, :service_unavailable } + + around_action :without_sentry_events + before_action :verify_token! + + def delete + raise PrivacyToken::Invalid unless [ true, false ].include?(@payload["dry_run"]) + + render_result PrivacyDeletion.new(@payload["subject"], dry_run: @payload["dry_run"]).call + end + + def export + render_result PrivacyDeletion.new(@payload["subject"], dry_run: true).call + end + + private + + # Sentry sends request headers and SQL breadcrumbs, which would include the token and the subject's emails. + def without_sentry_events + Sentry.with_scope do |scope| + scope&.add_event_processor { nil } + yield + end + end + + def verify_token! + token = request.authorization.to_s[/\ABearer (\S+)\z/, 1] + @payload = PrivacyToken.verify!(token, action: action_name) + end + + def render_result(result) + log_outcome(result.found? ? "found" : "not_found") + render json: { + request_id: @payload["request_id"], + found: result.found?, + dry_run: @payload["dry_run"], + deleted: result.deleted, + retained: result.retained + } + end + + def render_error(code, status, **details) + log_outcome(code, **details) + render json: { error: code }, status: status + end + + def log_outcome(outcome, **details) + Rails.logger.tagged("PrivacyRequest") do + Rails.logger.info({ event: "privacy_request", request_id: @payload&.dig("request_id"), action: action_name, outcome: outcome, **details }.to_json) + end + end +end diff --git a/app/models/marksmith/renderer.rb b/app/models/marksmith/renderer.rb index 93860475..ac420f58 100644 --- a/app/models/marksmith/renderer.rb +++ b/app/models/marksmith/renderer.rb @@ -57,6 +57,25 @@ def initialize(options = {}) super(options) end + def self.find_blob_from_url(src) + # Standard ActiveStorage URLs + if (match = src.match(%r{/rails/active_storage/blobs/(?:redirect/|proxy/)?([^/]+)/})) + return ActiveStorage::Blob.find_signed(match[1]) + end + + # Marksmith/user-attachments URLs (Base64-encoded JSON with blob_id) + if (match = src.match(%r{/user-attachments/blobs/(?:redirect/|proxy/)?([^/]+)/})) + token = match[1].split("--").first + decoded = JSON.parse(Base64.decode64(token)) + blob_id = decoded.dig("_rails", "data") || decoded["data"] + return ActiveStorage::Blob.find_by(id: blob_id) + end + + nil + rescue ActiveSupport::MessageVerifier::InvalidSignature, JSON::ParserError, ArgumentError + nil + end + def image(src, title, alt_text) optimized_src = optimize_image_src(src) @@ -147,22 +166,7 @@ def local_active_storage_blob?(src) end def find_blob_from_url(src) - # Standard ActiveStorage URLs - if (match = src.match(%r{/rails/active_storage/blobs/(?:redirect/|proxy/)?([^/]+)/})) - return ActiveStorage::Blob.find_signed(match[1]) - end - - # Marksmith/user-attachments URLs (Base64-encoded JSON with blob_id) - if (match = src.match(%r{/user-attachments/blobs/(?:redirect/|proxy/)?([^/]+)/})) - token = match[1].split("--").first - decoded = JSON.parse(Base64.decode64(token)) - blob_id = decoded.dig("_rails", "data") || decoded["data"] - return ActiveStorage::Blob.find_by(id: blob_id) - end - - nil - rescue ActiveSupport::MessageVerifier::InvalidSignature, JSON::ParserError, ArgumentError - nil + self.class.find_blob_from_url(src) end def host_for_urls diff --git a/app/models/privacy_token_use.rb b/app/models/privacy_token_use.rb new file mode 100644 index 00000000..a7d9c7fe --- /dev/null +++ b/app/models/privacy_token_use.rb @@ -0,0 +1,24 @@ +# == Schema Information +# +# Table name: privacy_token_uses +# +# id :bigint not null, primary key +# jti :string not null +# used_at :datetime not null +# +# Indexes +# +# index_privacy_token_uses_on_jti (jti) UNIQUE +# index_privacy_token_uses_on_used_at (used_at) +# +class PrivacyTokenUse < ApplicationRecord + RETENTION = 1.day + + def self.first_use?(jti) + insert({ jti: jti }, unique_by: :jti, returning: :id).rows.any? + end + + def self.prune! + where(used_at: ...RETENTION.ago).delete_all + end +end diff --git a/app/services/privacy_deletion.rb b/app/services/privacy_deletion.rb new file mode 100644 index 00000000..e2d9e8cf --- /dev/null +++ b/app/services/privacy_deletion.rb @@ -0,0 +1,244 @@ +class PrivacyDeletion + class ManualReviewRequired < StandardError; end + class ExternalFailure < StandardError; end + + STAFF_FLAGS = %i[admin reviewer fulfiller shopkeeper hcb_integration_enabled].freeze + OPEN_ORDER_STATES = %i[pending awaiting_verification on_hold].freeze + BLOB_URL_PATTERN = %r{/(?:rails/active_storage|user-attachments)/blobs/(?:redirect/|proxy/)?[^/\s)"'<>]+/} + PLACEHOLDER_PROJECT_TITLE = "Deleted project" + + attr_reader :deleted, :retained + + def initialize(subject, dry_run:) + @emails = Array(subject["emails"]).filter_map { |email| email.to_s.strip.downcase.presence }.uniq + @slack_id = subject["slack_id"].presence + @identity_id = subject["identity_id"].presence + @dry_run = dry_run + @deleted = {} + @retained = [] + @users = [] + end + + def call + ActiveRecord::Base.transaction(requires_new: true) do + @users = matching_users.lock.to_a + raise ManualReviewRequired if manual_review_required? + + erase! + purge_external! unless @dry_run + AirtableSync.where(record_identifier: @airtable_identifiers).delete_all + raise ActiveRecord::Rollback if @dry_run + end + self + end + + def found? + @users.any? || @deleted.any? + end + + private + + def matching_users + conditions = [] + if @emails.any? + conditions << User.where("LOWER(users.email) IN (?)", @emails) + conditions << User.where("LOWER(users.email) ~ ANY (ARRAY[?])", @emails.map { |email| old_email_pattern(email) }) + conditions << User.where(id: GuildSignup.where("LOWER(guild_signups.email) IN (?)", @emails).select(:user_id)) + end + conditions << User.where(slack_id: @slack_id) if @slack_id + conditions << User.where(identity_vault_id: @identity_id) if @identity_id + conditions.reduce(:or) || User.none + end + + # DuplicateUserMerger renames merged accounts to local+old@domain, local+old2@domain, ... + def old_email_pattern(email) + local, domain = email.split("@", 2) + "^#{Regexp.escape(local)}\\+old[0-9]*@#{Regexp.escape(domain.to_s)}$" + end + + def manual_review_required? + @users.any? { |user| STAFF_FLAGS.any? { |flag| user.public_send(flag) } } || + GuildSignup.organizer.where(user_id: @users.map(&:id)).joins(:guild).merge(Guild.where.not(status: :closed)).exists? + end + + def erase! + user_ids = @users.map(&:id) + project_ids = Project.where(user_id: user_ids).ids + kept_project_ids = reviewed_funded_or_shipped(project_ids) + entries = JournalEntry.where(user_id: user_ids).or(JournalEntry.where(project_id: project_ids)) + entry_ids = entries.ids + @guild_signup_ids = GuildSignup.where(user_id: user_ids).ids + @gorse_item_ids = project_ids.map { |id| GorseService.project_item_id(id) } + entry_ids.map { |id| GorseService.entry_item_id(id) } + @airtable_identifiers = (project_ids - kept_project_ids).map { |id| "Project##{id}" } + @guild_signup_ids.map { |id| "GuildSignup##{id}" } + + erase_uploads(project_ids, entry_ids) + erase_contributions(user_ids, project_ids) + erase_journal_entries(entries, project_ids) + erase_projects(project_ids, kept_project_ids, entry_ids) + erase_account_records(user_ids, project_ids) + detach_retained_records(user_ids, project_ids) + end + + def reviewed_funded_or_shipped(project_ids) + ( + DesignReview.where(project_id: project_ids).distinct.pluck(:project_id) + + BuildReview.where(project_id: project_ids).distinct.pluck(:project_id) + + ProjectGrant.where(project_id: project_ids).distinct.pluck(:project_id) + + Package.where(trackable_type: "Project", trackable_id: project_ids).distinct.pluck(:trackable_id) + ).uniq + end + + def erase_uploads(project_ids, entry_ids) + attachments = ActiveStorage::Attachment.where(record_type: "Project", record_id: project_ids) + .or(ActiveStorage::Attachment.where(record_type: "JournalEntry", record_id: entry_ids)) + texts = JournalEntry.where(id: entry_ids).pluck(:content) + Project.where(id: project_ids).pluck(:description) + blob_ids = attachments.pluck(:blob_id) + embedded_blob_ids(texts) + + variant_ids = ActiveStorage::VariantRecord.where(blob_id: blob_ids).ids + blob_ids += ActiveStorage::Attachment.where(record_type: "ActiveStorage::VariantRecord", record_id: variant_ids) + .or(ActiveStorage::Attachment.where(record_type: "ActiveStorage::Blob", record_id: blob_ids)) + .pluck(:blob_id) + blob_ids.uniq! + @blobs = ActiveStorage::Blob.where(id: blob_ids).to_a + + tally :active_storage_attachments, ActiveStorage::Attachment.where(blob_id: blob_ids).delete_all + tally :active_storage_variant_records, ActiveStorage::VariantRecord.where(id: variant_ids).delete_all + tally :active_storage_blobs, ActiveStorage::Blob.where(id: blob_ids).delete_all + end + + def embedded_blob_ids(texts) + texts.compact.flat_map { |text| text.scan(BLOB_URL_PATTERN) }.uniq + .filter_map { |url| Marksmith::Renderer::LinkRenderer.find_blob_from_url(url)&.id } + end + + def erase_contributions(user_ids, project_ids) + kudos = Kudo.where(user_id: user_ids).or(Kudo.where(project_id: project_ids)) + kudos_project_ids = kudos.where.not(project_id: project_ids).distinct.pluck(:project_id) + tally :kudos, kudos.delete_all + Project.where(id: kudos_project_ids) + .update_all("kudos_count = (SELECT COUNT(*) FROM kudos WHERE kudos.project_id = projects.id)") + + tally :follows, Follow.where(user_id: user_ids).or(Follow.where(project_id: project_ids)).delete_all + + user_ids.each do |user_id| + viewed = ProjectUserView.where(user_id: user_id).where.not(project_id: project_ids).select(:project_id) + Project.where(id: viewed).update_all("views_count = GREATEST(views_count - 1, 0)") + JournalEntry.where("? = ANY(views)", user_id).update_all([ "views = array_remove(views, ?)", user_id ]) + end + tally :project_user_views, ProjectUserView.where(user_id: user_ids).or(ProjectUserView.where(project_id: project_ids)).delete_all + end + + def erase_journal_entries(entries, project_ids) + other_project_ids = entries.where.not(project_id: project_ids).distinct.pluck(:project_id) + tally :journal_entries, entries.delete_all + Project.where(id: other_project_ids) + .update_all("journal_entries_count = (SELECT COUNT(*) FROM journal_entries WHERE journal_entries.project_id = projects.id)") + end + + def erase_projects(project_ids, kept_project_ids, entry_ids) + tally :timeline_items, TimelineItem.where(project_id: project_ids).delete_all + tally :ai_reviews, AiReview.where(project_id: project_ids).delete_all + tally :disco_recommendations, Disco::Recommendation.where(item_type: "Project", item_id: project_ids) + .or(Disco::Recommendation.where(item_type: "JournalEntry", item_id: entry_ids)) + .delete_all + tally :projects, Project.where(id: project_ids - kept_project_ids).delete_all + + retain "projects", + Project.where(id: kept_project_ids).update_all( + title: PLACEHOLDER_PROJECT_TITLE, description: nil, demo_link: nil, readme_link: nil, repo_link: nil, + hackatime_project_keys: [], is_deleted: true, unlisted: true, skip_gh_sync: true, updated_at: Time.current + ), + "reviewed, funded or shipped projects are kept as anonymous placeholders because reviews, grants and packages reference them; title, description, links and files are wiped" + end + + def erase_account_records(user_ids, project_ids) + expiry_ids = PrivilegedSessionExpiry.where(user_id: user_ids).ids + emails = (@emails + @users.map { |user| user.email.downcase }).uniq + allowed_email_ids = AllowedEmail.where("LOWER(email) IN (?)", emails).ids + + tally :guild_signups, GuildSignup.where(id: @guild_signup_ids).delete_all + tally :task_lists, TaskList.where(user_id: user_ids).delete_all + tally :privileged_session_expiries, PrivilegedSessionExpiry.where(id: expiry_ids).delete_all + tally :disco_recommendations, Disco::Recommendation.where(subject_type: "User", subject_id: user_ids).delete_all + + visits = Ahoy::Visit.where(user_id: user_ids) + tally :ahoy_events, Ahoy::Event.where(user_id: user_ids).or(Ahoy::Event.where(visit_id: visits.select(:id))).delete_all + tally :ahoy_visits, visits.delete_all + + tally :one_time_passwords, OneTimePassword.where("LOWER(email) IN (?)", emails).delete_all + tally :email_tracks, EmailTrack.where("LOWER(email) IN (?)", emails).delete_all + tally :allowed_emails, AllowedEmail.where(id: allowed_email_ids).delete_all + + tally :versions, PaperTrail::Version.where(item_type: "User", item_id: user_ids) + .or(PaperTrail::Version.where(item_type: "Project", item_id: project_ids)) + .or(PaperTrail::Version.where(item_type: "PrivilegedSessionExpiry", item_id: expiry_ids)) + .or(PaperTrail::Version.where(item_type: "AllowedEmail", item_id: allowed_email_ids)) + .delete_all + PaperTrail::Version.where(whodunnit: user_ids.map(&:to_s)).update_all(whodunnit: nil) + + tally :flipper_gates, Flipper::Adapters::ActiveRecord::Gate.where(key: "actors", value: user_ids.map { |id| "User;#{id}" }).delete_all + tally :airtable_syncs, AirtableSync.where(record_identifier: @airtable_identifiers).count + end + + def detach_retained_records(user_ids, project_ids) + now = Time.current + + Project.where(design_review_claimed_by_id: user_ids).update_all(design_review_claimed_by_id: nil, design_review_claimed_at: nil) + Project.where(build_review_claimed_by_id: user_ids).update_all(build_review_claimed_by_id: nil, build_review_claimed_at: nil) + + orders = ShopOrder.where(user_id: user_ids) + orders.where(state: OPEN_ORDER_STATES).update_all( + state: ShopOrder.states[:rejected], rejected_at: now, rejected_by_id: nil, rejection_reason: "Account deleted", updated_at: now + ) + retain "shop orders", + orders.update_all(frozen_address: nil, phone_number: nil, tracking_number: nil, updated_at: now), + "kept for accounting and fulfilment; detached from the account, address, phone and tracking number wiped, open orders rejected" + + packages = Package.where(trackable_type: "User", trackable_id: user_ids) + .or(Package.where(trackable_type: "Project", trackable_id: project_ids)) + .or(Package.where(trackable_type: "ShopOrder", trackable_id: orders.select(:id))) + retain "packages", + packages.update_all( + recipient_name: nil, recipient_email: nil, address_line_1: nil, address_line_2: nil, city: nil, + state: nil, postal_code: nil, country: nil, tracking_number: nil, updated_at: now + ), + "shipping records kept for postage cost accounting; recipient name, email, address and tracking number wiped" + + retain "design reviews", DesignReview.where(project_id: project_ids).or(DesignReview.where(reviewer_id: user_ids)).count, + "reviews back funding decisions and reviewer stats for other people; they now point at an anonymous placeholder" + retain "build reviews", BuildReview.where(project_id: project_ids).or(BuildReview.where(reviewer_id: user_ids)).count, + "reviews back ticket awards and reviewer stats for other people; they now point at an anonymous placeholder" + retain "project grants", ProjectGrant.where(project_id: project_ids).count, + "record of grant money issued, kept for accounting; attached to an anonymous placeholder project" + retain "manual ticket adjustments", ManualTicketAdjustment.where(user_id: user_ids).count, + "ticket ledger entries kept for accounting; attached to an anonymous placeholder account" + + @users.each do |user| + user.update_columns( + email: "deleted-#{user.id}@deleted.invalid", username: nil, avatar: nil, slack_id: nil, timezone_raw: nil, + github_username: nil, github_installation_id: nil, identity_vault_id: nil, identity_vault_access_token: nil, + identity_vault_refresh_token: nil, identity_vault_token_expires_at: nil, birthday: nil, idv_country: nil, + internal_notes: nil, last_active: nil, last_impersonated_at: nil, last_impersonation_ended_at: nil, updated_at: now + ) + end + retain "users", @users.size, + "account row kept as an anonymous placeholder because orders, reviews, grants and ticket adjustments reference it; every personal field is wiped" + end + + def purge_external! + @blobs.each(&:delete) + @users.each { |user| GorseService.delete_user(user) } + @gorse_item_ids.each { |item_id| GorseService.delete_item_by_id(item_id) } + @guild_signup_ids.each { |id| AirtableSync.delete_record!(GuildSignup, id) } + rescue StandardError + raise ExternalFailure + end + + def tally(type, rows) + @deleted[type.to_s] = @deleted.fetch(type.to_s, 0) + rows if rows.positive? + end + + def retain(type, rows, reason) + @retained << { type: type, count: rows, reason: reason } if rows.positive? + end +end diff --git a/app/services/privacy_token.rb b/app/services/privacy_token.rb new file mode 100644 index 00000000..d4c101e4 --- /dev/null +++ b/app/services/privacy_token.rb @@ -0,0 +1,87 @@ +class PrivacyToken + class Invalid < StandardError; end + class ActionMismatch < StandardError; end + class Replayed < StandardError; end + class Unavailable < StandardError; end + + ISSUER = "hackclub-privacy" + AUDIENCE = "blueprint" + ALGORITHM = "ES256" + LEEWAY = 30 + MAX_LIFETIME = 300 + JWKS_TTL = 5.minutes + JWKS_REFETCH_INTERVAL = 30.seconds + REQUIRED_CLAIMS = %w[iss aud iat exp jti request_id action subject].freeze + + JWKS_LOCK = Mutex.new + + class << self + def verify!(token, action:) + payload, header = decode(token) + + raise Invalid unless header["typ"] == "JWT" + raise Invalid unless valid_lifetime?(payload) + raise Invalid unless payload["subject"].is_a?(Hash) + raise ActionMismatch unless payload["action"] == action + raise Replayed unless PrivacyTokenUse.first_use?(payload["jti"].to_s) + + payload + end + + private + + def decode(token) + raise Invalid if token.blank? + + JWT.decode( + token, nil, true, + algorithms: [ ALGORITHM ], + jwks: method(:jwks), + iss: ISSUER, + verify_iss: true, + aud: AUDIENCE, + verify_aud: true, + leeway: LEEWAY, + required_claims: REQUIRED_CLAIMS + ) + rescue JWT::DecodeError + raise Invalid + end + + def valid_lifetime?(payload) + iat = payload["iat"] + exp = payload["exp"] + return false unless iat.is_a?(Numeric) && exp.is_a?(Numeric) + + now = Time.current.to_i + iat <= now + LEEWAY && iat >= now - MAX_LIFETIME - LEEWAY && exp - iat <= MAX_LIFETIME + end + + def jwks(options = {}) + JWKS_LOCK.synchronize do + max_age = options[:kid_not_found] ? JWKS_REFETCH_INTERVAL : JWKS_TTL + if @jwks.nil? || @jwks_fetched_at < max_age.ago + @jwks = fetch_jwks + @jwks_fetched_at = Time.current + end + @jwks + end + end + + def fetch_jwks + response = Faraday.get(jwks_url) do |req| + req.options.open_timeout = 3 + req.options.timeout = 5 + end + raise Unavailable unless response.success? + + JSON.parse(response.body) + rescue Faraday::Error, JSON::ParserError + raise Unavailable + end + + def jwks_url + ENV["PRIVACY_JWKS_URL"].presence || "https://yeet.hackclub.com/.well-known/jwks.json" + end + end +end diff --git a/config/recurring.yml b/config/recurring.yml index aac61e82..becdc1e8 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -20,6 +20,11 @@ production: queue: background schedule: every hour + prune_privacy_token_uses: + command: "PrivacyTokenUse.prune!" + queue: background + schedule: every day at 4am + uptime_ping: class: UptimePingJob queue: uptime diff --git a/config/routes.rb b/config/routes.rb index 219e1e08..94d041e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -162,6 +162,8 @@ def self.matches?(request) post "api/report_grant_given" => "api#report_grant_given", as: :api_report_grant_given post "api/report_free_stickers_claimed" => "api#report_free_stickers_claimed", as: :api_report_free_stickers_claimed get "api/unfinished_projects" => "api#unfinished_projects", as: :api_unfinished_projects + post "api/privacy/delete" => "privacy#delete", as: :api_privacy_delete + post "api/privacy/export" => "privacy#export", as: :api_privacy_export resources :projects, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do member do diff --git a/db/migrate/20261013120605_create_privacy_token_uses.rb b/db/migrate/20261013120605_create_privacy_token_uses.rb new file mode 100644 index 00000000..db852e45 --- /dev/null +++ b/db/migrate/20261013120605_create_privacy_token_uses.rb @@ -0,0 +1,10 @@ +class CreatePrivacyTokenUses < ActiveRecord::Migration[8.1] + def change + create_table :privacy_token_uses do |t| + t.string :jti, null: false + t.datetime :used_at, null: false, default: -> { "CURRENT_TIMESTAMP" } + end + add_index :privacy_token_uses, :jti, unique: true + add_index :privacy_token_uses, :used_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 733330a2..50d5685c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_03_123729) do +ActiveRecord::Schema[8.0].define(version: 2026_10_13_120605) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -420,6 +420,13 @@ t.index ["trackable_type", "trackable_id"], name: "index_packages_on_trackable" end + create_table "privacy_token_uses", force: :cascade do |t| + t.string "jti", null: false + t.datetime "used_at", default: -> { "CURRENT_TIMESTAMP" }, null: false + t.index ["jti"], name: "index_privacy_token_uses_on_jti", unique: true + t.index ["used_at"], name: "index_privacy_token_uses_on_used_at" + end + create_table "privileged_session_expiries", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 00000000..aea30386 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,94 @@ +# Privacy requests (deletion and export) + +Deletion and export requests come from the Hack Club privacy hub. The hub calls `delete` once with `dry_run: true`, staff review the counts, and after approval the hub calls again with `dry_run: false`. + +| Endpoint | Does | +| --- | --- | +| `POST /api/privacy/delete` | Deletes everything belonging to the person, or reports what it would delete on a dry run | +| `POST /api/privacy/export` | Reports what is held. It always runs as a dry run, so it never changes anything | + +## Authentication + +Each call carries `Authorization: Bearer `, an ES256 JWT signed by the hub. `PrivacyToken` verifies it with the `jwt` gem against the hub's JWKS at `https://yeet.hackclub.com/.well-known/jwks.json` (override with `PRIVACY_JWKS_URL`). + +The verifier checks: + +- the signature against the key named by `kid`, accepting only `alg: ES256` and `typ: JWT` +- `iss` is `hackclub-privacy` and `aud` is exactly `blueprint` +- `exp` and `iat` with 30 seconds of leeway, and `exp - iat` is at most 300 seconds +- `action` matches the endpoint +- `jti` has not been seen before + +Keys are cached for 5 minutes and refetched (at most every 30 seconds) when an unknown `kid` shows up, so key rotation on the hub needs no change here. + +Each `jti` is recorded in `privacy_token_uses` before any work starts, and rows older than a day are pruned by the `prune_privacy_token_uses` recurring task. + +The person is found only from the token's `subject` claims, never the request body or query string: + +- `emails`, matched case-insensitively against `users.email`, the `+old` emails left behind by `DuplicateUserMerger`, and `guild_signups.email` +- `slack_id`, matched against `users.slack_id` +- `identity_id`, matched against `users.identity_vault_id` + +The response is `200 {request_id, found, dry_run, deleted: {table: rows}, retained: [{type, count, reason}]}`. Once someone is deleted, later calls answer `found: false`. + +Errors: + +| Status | Error | When | +| --- | --- | --- | +| 401 | `invalid_token` | Missing, malformed, expired, or wrongly signed token | +| 400 | `action_mismatch` | A delete token sent to `/export`, or the reverse | +| 409 | `replayed` | The `jti` was already used | +| 409 | `manual_review_required` | See below | +| 503 | `jwks_unavailable` | The hub's keys can't be fetched (the hub retries) | +| 503 | `storage_unavailable` | R2 or Gorse couldn't be reached during a real deletion (the hub retries) | +| 500 | `internal_error` | Anything else (the hub retries) | + +Only the `request_id`, the action, and the outcome are logged. Tokens, emails, Slack IDs, and identity IDs never reach the logs or the response. + +## Needs a human + +`PrivacyDeletion` refuses with `409 manual_review_required` and changes nothing if a matched account: + +- is an admin, reviewer, fulfiller, or shopkeeper +- has the HCB integration enabled, because it holds the program's HCB OAuth tokens +- organizes a guild that isn't closed, because removing them changes the guild's status and Slack channel for its attendees + +## How a run works + +The dry run and the real run share one code path. `PrivacyDeletion` does the whole deletion inside a transaction, counting rows as it goes. On a dry run (and on every export) it then rolls back. On a real run it deletes the external copies (R2 files, Gorse entries, Airtable guild signups) before committing. If R2 or Gorse can't be reached the transaction rolls back and returns `503`, so the retry still finds the person. Airtable delete failures are logged by `AirtableSync.delete_record!` and don't block the deletion. + +Rows are removed with `delete_all` and `update_all`, so model callbacks (Slack, Airtable, Gorse, emails) never fire during a dry run. + +## What gets deleted + +- **Account data:** guild signups, task list, privileged session expiry, recommendations, and ahoy visits and events +- **Content:** journal entries they wrote or that sit on their projects, kudos and follows they gave or received, unique project views, timeline items, and AI reviews of their projects +- **Projects:** every project with no reviews, grants, or packages +- **Uploads:** banners, demo pictures, cart screenshots, journal attachments, and images embedded in journal or project markdown, with their variants. The R2 objects are deleted on the real run, before commit +- **Email-keyed rows:** one-time passwords, email tracks, and allowed emails, matched against the token's emails and the account's email +- **Audit rows:** paper_trail versions of their user, projects, session expiry, and allowed emails. `whodunnit` pointing at them is cleared +- **Bookkeeping:** Flipper actor gates and Airtable sync rows for deleted records +- **External copies:** the Gorse user and its project and journal items, and their Airtable guild signup records + +Other people's numbers are fixed in the same transaction: `kudos_count` and `journal_entries_count` are recounted, `views_count` is decremented on projects they viewed, and their ID is removed from `journal_entries.views`. Review claims they held are released. + +## What gets kept + +Kept rows are anonymized in place and reported in `retained`: + +| Kept | How | Why | +| --- | --- | --- | +| User row | Email becomes `deleted-@deleted.invalid`. Username, avatar, Slack ID, timezone, GitHub fields, identity vault ID and tokens, birthday, IDV country, internal notes, and activity timestamps are wiped. Ban status and referrer are kept | Orders, reviews, grants, and ticket adjustments reference it | +| Reviewed, funded, or shipped projects | Title becomes "Deleted project". Description, links, Hackatime keys, and files are wiped, and the project is marked deleted and unlisted | Reviews, grants, and packages reference them | +| Design and build reviews | Unchanged | They back funding and ticket decisions and reviewer stats | +| Project grants | Unchanged | Record of money granted | +| Manual ticket adjustments | Unchanged | Ticket ledger | +| Shop orders | Address, phone number, and tracking number wiped. Pending, awaiting verification, and on hold orders are rejected with "Account deleted" | Accounting and fulfilment | +| Packages | Recipient name, email, address, and tracking number wiped | Postage cost accounting | + +## Not handled here + +- **HCB:** `hcb_grants` and `hcb_transactions` are a ledger mirrored from HCB, linked by display name only, and are left alone +- **Airtable:** the users, projects, and shop orders tables are filled by the bulk sync, so the next scheduled sync overwrites them with the anonymized values. Whether rows for deleted projects disappear depends on that Airtable sync's settings +- **Elsewhere:** Slack, Hackatime, Loops, Hack Club Auth, Sentry, and the `JOURNAL.md` in the person's own GitHub repo are handled by their own systems or the hub's other targets +- **Short-lived data:** Solid Queue jobs and Solid Cache entries expire on their own From 5b18a98fc7db5fe0f44f7c06233b6a6b423acbeb Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 13 Oct 2026 09:03:41 -0400 Subject: [PATCH 2/4] update brakeman --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 62fb10a0..754cbbdd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,7 +129,7 @@ GEM safely_block (>= 1) bootsnap (1.25.0) msgpack (~> 1.5) - brakeman (8.0.6) + brakeman (8.1.0) racc builder (3.3.0) bullet (8.2.0) From 0ab058d9bb8168da075c72ecebfb2b8538153206 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 13 Oct 2026 09:06:09 -0400 Subject: [PATCH 3/4] Fix annotate check: give CI a database and refresh annotations --- .github/workflows/ci.yml | 17 ++ app/models/ai_review.rb | 2 +- app/models/shop_order.rb | 2 +- config/routes.rb | 312 +++++++++++++++++++++++++++++---- test/fixtures/shop_orders.yml | 2 +- test/models/shop_order_test.rb | 2 +- 6 files changed, 303 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c94995d..b94c2f2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,20 @@ jobs: annotate_check: runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 + + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/blueprint_development + steps: - name: Checkout code uses: actions/checkout@v7 @@ -68,6 +82,9 @@ jobs: ruby-version: .ruby-version bundler-cache: true + - name: Load database schema + run: bin/rails db:create db:schema:load + - name: Check if annotations are up to date run: | bundle exec annotaterb models --frozen --show-migration diff --git a/app/models/ai_review.rb b/app/models/ai_review.rb index 9172a7fc..f445e3f6 100644 --- a/app/models/ai_review.rb +++ b/app/models/ai_review.rb @@ -14,7 +14,7 @@ # retry_count :integer default(0), not null # review_phase :string not null # started_at :datetime -# status :string default(NULL), not null +# status :string default("pending"), not null # steps :jsonb not null # total_tokens :integer # created_at :datetime not null diff --git a/app/models/shop_order.rb b/app/models/shop_order.rb index f7f4c1db..0a527870 100644 --- a/app/models/shop_order.rb +++ b/app/models/shop_order.rb @@ -16,7 +16,7 @@ # quantity :integer # rejected_at :datetime # rejection_reason :string -# state :integer default("pending"), not null +# state :integer default(0), not null # tracking_number :string # created_at :datetime not null # updated_at :datetime not null diff --git a/config/routes.rb b/config/routes.rb index 94d041e4..ca8235fc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,35 +1,287 @@ # == Route Map # -# Prefix Verb URI Pattern Controller#Action -# /assets Propshaft::Server -# rails_health_check GET /up(.:format) rails/health#show -# root GET / landing#index -# turbo_recede_historical_location GET /recede_historical_location(.:format) turbo/native/navigation#recede -# turbo_resume_historical_location GET /resume_historical_location(.:format) turbo/native/navigation#resume -# turbo_refresh_historical_location GET /refresh_historical_location(.:format) turbo/native/navigation#refresh -# rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create -# rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create -# rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create -# rails_mandrill_inbound_health_check GET /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#health_check -# rails_mandrill_inbound_emails POST /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#create -# rails_mailgun_inbound_emails POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) action_mailbox/ingresses/mailgun/inbound_emails#create -# rails_conductor_inbound_emails GET /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#index -# POST /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#create -# new_rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/new(.:format) rails/conductor/action_mailbox/inbound_emails#new -# rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/:id(.:format) rails/conductor/action_mailbox/inbound_emails#show -# new_rails_conductor_inbound_email_source GET /rails/conductor/action_mailbox/inbound_emails/sources/new(.:format) rails/conductor/action_mailbox/inbound_emails/sources#new -# rails_conductor_inbound_email_sources POST /rails/conductor/action_mailbox/inbound_emails/sources(.:format) rails/conductor/action_mailbox/inbound_emails/sources#create -# rails_conductor_inbound_email_reroute POST /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format) rails/conductor/action_mailbox/reroutes#create -# rails_conductor_inbound_email_incinerate POST /rails/conductor/action_mailbox/:inbound_email_id/incinerate(.:format) rails/conductor/action_mailbox/incinerates#create -# rails_service_blob GET /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format) active_storage/blobs/redirect#show -# rails_service_blob_proxy GET /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format) active_storage/blobs/proxy#show -# GET /rails/active_storage/blobs/:signed_id/*filename(.:format) active_storage/blobs/redirect#show -# rails_blob_representation GET /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show -# rails_blob_representation_proxy GET /rails/active_storage/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/proxy#show -# GET /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show -# rails_disk_service GET /rails/active_storage/disk/:encoded_key/*filename(.:format) active_storage/disk#show -# update_rails_disk_service PUT /rails/active_storage/disk/:encoded_token(.:format) active_storage/disk#update -# rails_direct_uploads POST /rails/active_storage/direct_uploads(.:format) active_storage/direct_uploads#create +# Routes for application: +# Prefix Verb URI Pattern Controller#Action +# slack_commands POST /slack/commands(.:format) slack_commands#handle +# guild_signups POST /guild_signups(.:format) guild_signups#create +# new_guild_signup GET /guild_signups/new(.:format) guild_signups#new +# guild_signups_signup POST /guild_signups/signup(.:format) guild_signups#signup +# guild_signups_verify POST /guild_signups/verify(.:format) guild_signups#verify +# guild_signups_success GET /guild_signups/success(.:format) guild_signups#success +# shop_items POST /shop_items(.:format) shop_items#create +# new_shop_item GET /shop_items/new(.:format) shop_items#new +# shop_orders GET /shop_orders(.:format) shop_orders#index +# POST /shop_orders(.:format) shop_orders#create +# new_shop_order GET /shop_orders/new(.:format) shop_orders#new +# buy_item GET /buy/:item_id(.:format) shop_orders#new +# rails_health_check GET /up(.:format) rails/health#show +# root GET / landing#index +# landing GET /landing(.:format) landing#authed +# countdown GET /countdown(.:format) countdown#index +# sorry GET /sorry(.:format) sorry#index +# adult GET /adult(.:format) adult#index +# referral GET /r/:id(.:format) referral#show +# guilds_map_data GET /guilds/map_data(.:format) guilds#map_data +# guild_dashboard GET /guilds/dashboard(.:format) guilds#dashboard +# guild_create_announcement POST /guilds/dashboard/announce(.:format) guilds#create_announcement +# guild_delete_announcement DELETE /guilds/dashboard/announce(.:format) guilds#delete_announcement +# guild_leave DELETE /guilds/dashboard/leave(.:format) guilds#leave_guild +# guild_close_signups POST /guilds/dashboard/close_signups(.:format) guilds#close_signups +# guild_reopen_signups POST /guilds/dashboard/reopen_signups(.:format) guilds#reopen_signups +# guild_dismiss_notice POST /guilds/dashboard/dismiss_notice/:key(.:format) guilds#dismiss_notice +# login GET /auth/login(.:format) auth#index +# slack_login GET /auth/slack(.:format) auth#new +# github_login GET /auth/github(.:format) auth#github +# hca_login GET /auth/hca(.:format) auth#new_hca +# slack_callback GET /auth/slack/callback(.:format) auth#create +# github_callback GET /auth/github/callback(.:format) auth#create_github +# hca_callback GET /auth/hca/callback(.:format) auth#create_hca +# login_email POST /auth/email(.:format) auth#create_email +# logout DELETE /auth/logout(.:format) auth#destroy +# auth_tracks POST /auth/track(.:format) auth#track +# idv GET /auth/idv(.:format) auth#idv +# idv_start GET /auth/idv/start(.:format) auth#idv_start +# idv_callback GET /idv_callback(.:format) auth#idv_callback +# age_verification GET /auth/age(.:format) auth#age +# auth_age POST /auth/age(.:format) auth#submit_age +# hcb_oauth GET /auth/hcb(.:format) hcb_oauth#start +# hcb_callback GET /auth/hcb/callback(.:format) hcb_oauth#callback +# hcb_disconnect DELETE /auth/hcb(.:format) hcb_oauth#disconnect +# utm_source GET /utm_source(.:format) landing#utm_source +# utm_source_feedback_email GET /utm_source=feedback-email(.:format) landing#utm_source +# amd GET /amd(.:format) amd#index +# home GET /home(.:format) home#index +# api_site GET /api/site(.:format) api#site +# api_stickers POST /api/stickers(.:format) api#stickers +# api_report_grant_given POST /api/report_grant_given(.:format) api#report_grant_given +# api_report_free_stickers_claimed POST /api/report_free_stickers_claimed(.:format) api#report_free_stickers_claimed +# api_unfinished_projects GET /api/unfinished_projects(.:format) api#unfinished_projects +# api_privacy_delete POST /api/privacy/delete(.:format) privacy#delete +# api_privacy_export POST /api/privacy/export(.:format) privacy#export +# ship_project GET /projects/:id/ship(.:format) projects#ship +# follow_project POST /projects/:id/follow(.:format) projects#follow +# unfollow_project POST /projects/:id/unfollow(.:format) projects#unfollow +# export_project_journal_entries GET /projects/:project_id/journal_entries/export(.:format) journal_entries#export +# project_journal_entries POST /projects/:project_id/journal_entries(.:format) journal_entries#create +# edit_project_journal_entry GET /projects/:project_id/journal_entries/:id/edit(.:format) journal_entries#edit +# project_journal_entry GET /projects/:project_id/journal_entries/:id(.:format) journal_entries#show +# PATCH /projects/:project_id/journal_entries/:id(.:format) journal_entries#update +# PUT /projects/:project_id/journal_entries/:id(.:format) journal_entries#update +# DELETE /projects/:project_id/journal_entries/:id(.:format) journal_entries#destroy +# project_kudos POST /projects/:project_id/kudos(.:format) kudos#create +# project_kudo DELETE /projects/:project_id/kudos/:id(.:format) kudos#destroy +# check_github_repo_projects POST /projects/check_github_repo(.:format) projects#check_github_repo +# check_bom_projects POST /projects/check_bom(.:format) projects#check_bom +# check_readme_projects POST /projects/check_readme(.:format) projects#check_readme +# list_repos_projects GET /projects/list_repos(.:format) projects#list_repos +# projects GET /projects(.:format) projects#index +# POST /projects(.:format) projects#create +# new_project GET /projects/new(.:format) projects#new +# edit_project GET /projects/:id/edit(.:format) projects#edit +# project GET /projects/:id(.:format) projects#show +# PATCH /projects/:id(.:format) projects#update +# PUT /projects/:id(.:format) projects#update +# DELETE /projects/:id(.:format) projects#destroy +# explore GET /explore(.:format) projects#explore +# stats GET /stats(.:format) stats#index +# leaderboard GET /leaderboard(.:format) leaderboards#index +# guilds GET /guilds(.:format) guilds#index +# guild_invite GET /guilds/invite/:slug(.:format) guild_invites#show +# guild_invite_signup POST /guilds/invite/:slug/signup(.:format) guild_invites#signup +# guild_invite_verify POST /guilds/invite/:slug/verify(.:format) guild_invites#verify +# accept_guild_invite POST /guilds/invite/:slug/accept(.:format) guild_invites#accept +# guild_invite_success GET /guilds/invite/:slug/success(.:format) guild_invites#success +# toolbag GET /toolbag(.:format) toolbag#index +# me GET /users/me(.:format) users#me +# invite_to_slack_users POST /users/invite_to_slack(.:format) users#invite_to_slack +# mcg_check_users POST /users/mcg_check(.:format) users#mcg_check +# update_timezone_users POST /users/update_timezone(.:format) users#update_timezone +# toggle_pro_users POST /users/toggle_pro(.:format) users#toggle_pro +# user GET /users/:id(.:format) users#show +# team GET /team(.:format) team#index +# about GET /about(.:format) markdown#about +# GET /about/*slug(.:format) markdown#about +# docs GET /docs(.:format) markdown#docs +# GET /docs/*slug(.:format) markdown#docs +# resources GET /resources(.:format) markdown#resources +# GET /resources/*slug(.:format) markdown#resources {slug: /[a-z0-9\/_\-]+/} +# guides GET /guides(.:format) markdown#guides +# GET /guides/*slug(.:format) markdown#guides +# starter_projects GET /starter-projects(.:format) markdown#starter_projects +# GET /starter-projects/*slug(.:format) markdown#starter_projects +# hackpad GET /hackpad(.:format) markdown#hackpad +# GET /hackpad/*slug(.:format) markdown#hackpad +# faq GET /faq(.:format) markdown#faq +# prototype GET /prototype(.:format) prototype#index +# poster GET /posters/:id(.:format) posters#show +# customs_receipt_package GET /packages/:id/customs_receipt(.:format) packages#customs_receipt +# admin_stop_impersonating POST /admin/stop_impersonating(.:format) admin/users#stop_impersonating +# admin_mission_control_jobs /admin/jobs MissionControl::Jobs::Engine +# /admin/flipper Flipper::UI +# admin_blazer /admin/blazer Blazer::Engine +# admin_invalidate_privileged_sessions POST /admin/invalidate_privileged_sessions(.:format) admin/static_pages#invalidate_privileged_sessions +# preview_admin_pirate_ship_import POST /admin/pirate_ship_import/preview(.:format) admin/pirate_ship_imports#preview +# new_admin_pirate_ship_import GET /admin/pirate_ship_import/new(.:format) admin/pirate_ship_imports#new +# admin_pirate_ship_import POST /admin/pirate_ship_import(.:format) admin/pirate_ship_imports#create +# delete_admin_project POST /admin/projects/:id/delete(.:format) admin/projects#delete +# revive_admin_project POST /admin/projects/:id/revive(.:format) admin/projects#revive +# mark_viral_admin_project POST /admin/projects/:id/mark_viral(.:format) admin/projects#mark_viral +# unmark_viral_admin_project POST /admin/projects/:id/unmark_viral(.:format) admin/projects#unmark_viral +# toggle_unlisted_admin_project POST /admin/projects/:id/toggle_unlisted(.:format) admin/projects#toggle_unlisted +# switch_review_phase_admin_project POST /admin/projects/:id/switch_review_phase(.:format) admin/projects#switch_review_phase +# force_fix_review_status_admin_project POST /admin/projects/:id/force_fix_review_status(.:format) admin/projects#force_fix_review_status +# admin_projects GET /admin/projects(.:format) admin/projects#index +# admin_project GET /admin/projects/:id(.:format) admin/projects#show +# admin_allowed_emails GET /admin/allowed_emails(.:format) admin/allowed_emails#index +# POST /admin/allowed_emails(.:format) admin/allowed_emails#create +# admin_allowed_email DELETE /admin/allowed_emails/:id(.:format) admin/allowed_emails#destroy +# grant_admin_admin_user POST /admin/users/:id/grant_admin(.:format) admin/users#grant_admin +# revoke_admin_admin_user POST /admin/users/:id/revoke_admin(.:format) admin/users#revoke_admin +# grant_reviewer_admin_user POST /admin/users/:id/grant_reviewer(.:format) admin/users#grant_reviewer +# revoke_reviewer_admin_user POST /admin/users/:id/revoke_reviewer(.:format) admin/users#revoke_reviewer +# grant_fulfiller_admin_user POST /admin/users/:id/grant_fulfiller(.:format) admin/users#grant_fulfiller +# revoke_fulfiller_admin_user POST /admin/users/:id/revoke_fulfiller(.:format) admin/users#revoke_fulfiller +# grant_shopkeeper_admin_user POST /admin/users/:id/grant_shopkeeper(.:format) admin/users#grant_shopkeeper +# revoke_shopkeeper_admin_user POST /admin/users/:id/revoke_shopkeeper(.:format) admin/users#revoke_shopkeeper +# revoke_to_user_admin_user POST /admin/users/:id/revoke_to_user(.:format) admin/users#revoke_to_user +# grant_submission_bypass_admin_user POST /admin/users/:id/grant_submission_bypass(.:format) admin/users#grant_submission_bypass +# revoke_submission_bypass_admin_user POST /admin/users/:id/revoke_submission_bypass(.:format) admin/users#revoke_submission_bypass +# impersonate_admin_user POST /admin/users/:id/impersonate(.:format) admin/users#impersonate +# ban_admin_user POST /admin/users/:id/ban(.:format) admin/users#ban +# unban_admin_user POST /admin/users/:id/unban(.:format) admin/users#unban +# add_ticket_adjustment_admin_user POST /admin/users/:id/add_ticket_adjustment(.:format) admin/users#add_ticket_adjustment +# admin_users GET /admin/users(.:format) admin/users#index +# admin_user GET /admin/users/:id(.:format) admin/users#show +# admin_hcb_transactions GET /admin/hcb_transactions(.:format) admin/hcb_transactions#index +# admin_shop_item DELETE /admin/shop_items/:id(.:format) admin/shop_items#destroy +# admin_root GET /admin(.:format) admin/static_pages#index +# admin_guilds GET /admin/guilds(.:format) admin/guilds#index +# admin_guild GET /admin/guilds/:id(.:format) admin/guilds#show +# admin_design_reviews GET /admin/design_reviews(.:format) admin/design_reviews#index +# admin_next_design_review GET /admin/design_reviews/next(/:ysws_type)(.:format) admin/design_reviews#show_next +# admin_design_review GET /admin/design_reviews/:id(.:format) admin/design_reviews#show +# admin_design_review_create POST /admin/design_reviews/:id(.:format) admin/design_reviews#create +# admin_build_reviews GET /admin/build_reviews(.:format) admin/build_reviews#index +# admin_next_build_review GET /admin/build_reviews/next(/:ysws_type)(.:format) admin/build_reviews#show_next +# admin_build_review GET /admin/build_reviews/:id(.:format) admin/build_reviews#show +# admin_build_review_create POST /admin/build_reviews/:id(.:format) admin/build_reviews#create +# admin_promote_first_pass_build_review POST /admin/build_reviews/:id/promote_first_pass(.:format) admin/build_reviews#promote_first_pass +# admin_deny_first_pass_build_review POST /admin/build_reviews/:id/deny_first_pass(.:format) admin/build_reviews#deny_first_pass +# admin_ai_reviews GET /admin/ai_reviews(.:format) admin/ai_reviews#index +# admin_ai_review GET /admin/ai_reviews/:id(.:format) admin/ai_reviews#show +# admin_ai_review_create POST /admin/ai_reviews/:project_id(.:format) admin/ai_reviews#create +# GET /admin/projects(.:format) admin/projects#index +# GET /admin/projects/:id(.:format) admin/projects#show +# update_internal_notes_admin_user PATCH /admin/users/:id/update_internal_notes(.:format) admin/users#update_internal_notes +# GET /admin/users(.:format) admin/users#index +# GET /admin/users/:id(.:format) admin/users#show +# PATCH /admin/users/:id(.:format) admin/users#update +# PUT /admin/users/:id(.:format) admin/users#update +# approve_admin_shop_order POST /admin/shop_orders/:id/approve(.:format) admin/shop_orders#approve +# reject_admin_shop_order POST /admin/shop_orders/:id/reject(.:format) admin/shop_orders#reject +# hold_admin_shop_order POST /admin/shop_orders/:id/hold(.:format) admin/shop_orders#hold +# fulfill_admin_shop_order POST /admin/shop_orders/:id/fulfill(.:format) admin/shop_orders#fulfill +# update_notes_admin_shop_order PATCH /admin/shop_orders/:id/update_notes(.:format) admin/shop_orders#update_notes +# admin_shop_orders GET /admin/shop_orders(.:format) admin/shop_orders#index +# admin_shop_order GET /admin/shop_orders/:id(.:format) admin/shop_orders#show +# admin_shop_items POST /admin/shop_items(.:format) admin/shop_items#create +# new_admin_shop_item GET /admin/shop_items/new(.:format) admin/shop_items#new +# edit_admin_shop_item GET /admin/shop_items/:id/edit(.:format) admin/shop_items#edit +# PATCH /admin/shop_items/:id(.:format) admin/shop_items#update +# PUT /admin/shop_items/:id(.:format) admin/shop_items#update +# GET /admin/shop_items(.:format) admin/shop_items#index +# GET /admin/shop_items/:id(.:format) admin/shop_items#show +# turbo_recede_historical_location GET /recede_historical_location(.:format) turbo/native/navigation#recede +# turbo_resume_historical_location GET /resume_historical_location(.:format) turbo/native/navigation#resume +# turbo_refresh_historical_location GET /refresh_historical_location(.:format) turbo/native/navigation#refresh +# rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create +# rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create +# rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create +# rails_mandrill_inbound_health_check GET /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#health_check +# rails_mandrill_inbound_emails POST /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#create +# rails_mailgun_inbound_emails POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) action_mailbox/ingresses/mailgun/inbound_emails#create +# rails_conductor_inbound_emails GET /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#index +# POST /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#create +# new_rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/new(.:format) rails/conductor/action_mailbox/inbound_emails#new +# rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/:id(.:format) rails/conductor/action_mailbox/inbound_emails#show +# new_rails_conductor_inbound_email_source GET /rails/conductor/action_mailbox/inbound_emails/sources/new(.:format) rails/conductor/action_mailbox/inbound_emails/sources#new +# rails_conductor_inbound_email_sources POST /rails/conductor/action_mailbox/inbound_emails/sources(.:format) rails/conductor/action_mailbox/inbound_emails/sources#create +# rails_conductor_inbound_email_reroute POST /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format) rails/conductor/action_mailbox/reroutes#create +# rails_conductor_inbound_email_incinerate POST /rails/conductor/action_mailbox/:inbound_email_id/incinerate(.:format) rails/conductor/action_mailbox/incinerates#create +# rails_service_blob GET /user-attachments/blobs/redirect/:signed_id/*filename(.:format) active_storage/blobs/redirect#show +# rails_service_blob_proxy GET /user-attachments/blobs/proxy/:signed_id/*filename(.:format) active_storage/blobs/proxy#show +# GET /user-attachments/blobs/:signed_id/*filename(.:format) active_storage/blobs/redirect#show +# rails_blob_representation GET /user-attachments/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show +# rails_blob_representation_proxy GET /user-attachments/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/proxy#show +# GET /user-attachments/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show +# rails_disk_service GET /user-attachments/disk/:encoded_key/*filename(.:format) active_storage/disk#show +# update_rails_disk_service PUT /user-attachments/disk/:encoded_token(.:format) active_storage/disk#update +# rails_direct_uploads POST /user-attachments/direct_uploads(.:format) active_storage/direct_uploads#create +# marksmith /marksmith Marksmith::Engine +# +# Routes for MissionControl::Jobs::Engine: +# Prefix Verb URI Pattern Controller#Action +# application_queue_pause DELETE /applications/:application_id/queues/:queue_id/pause(.:format) mission_control/jobs/queues/pauses#destroy +# POST /applications/:application_id/queues/:queue_id/pause(.:format) mission_control/jobs/queues/pauses#create +# application_queues GET /applications/:application_id/queues(.:format) mission_control/jobs/queues#index +# application_queue GET /applications/:application_id/queues/:id(.:format) mission_control/jobs/queues#show +# application_job_retry POST /applications/:application_id/jobs/:job_id/retry(.:format) mission_control/jobs/retries#create +# application_job_discard POST /applications/:application_id/jobs/:job_id/discard(.:format) mission_control/jobs/discards#create +# application_job_dispatch POST /applications/:application_id/jobs/:job_id/dispatch(.:format) mission_control/jobs/dispatches#create +# application_bulk_retries POST /applications/:application_id/jobs/bulk_retries(.:format) mission_control/jobs/bulk_retries#create +# application_bulk_discards POST /applications/:application_id/jobs/bulk_discards(.:format) mission_control/jobs/bulk_discards#create +# application_job GET /applications/:application_id/jobs/:id(.:format) mission_control/jobs/jobs#show +# application_jobs GET /applications/:application_id/:status/jobs(.:format) mission_control/jobs/jobs#index +# application_workers GET /applications/:application_id/workers(.:format) mission_control/jobs/workers#index +# application_worker GET /applications/:application_id/workers/:id(.:format) mission_control/jobs/workers#show +# application_recurring_tasks GET /applications/:application_id/recurring_tasks(.:format) mission_control/jobs/recurring_tasks#index +# application_recurring_task GET /applications/:application_id/recurring_tasks/:id(.:format) mission_control/jobs/recurring_tasks#show +# PATCH /applications/:application_id/recurring_tasks/:id(.:format) mission_control/jobs/recurring_tasks#update +# PUT /applications/:application_id/recurring_tasks/:id(.:format) mission_control/jobs/recurring_tasks#update +# queues GET /queues(.:format) mission_control/jobs/queues#index +# queue GET /queues/:id(.:format) mission_control/jobs/queues#show +# job GET /jobs/:id(.:format) mission_control/jobs/jobs#show +# jobs GET /:status/jobs(.:format) mission_control/jobs/jobs#index +# root GET / mission_control/jobs/queues#index +# +# Routes for Blazer::Engine: +# Prefix Verb URI Pattern Controller#Action +# run_queries POST /queries/run(.:format) blazer/queries#run +# cancel_queries POST /queries/cancel(.:format) blazer/queries#cancel +# refresh_query POST /queries/:id/refresh(.:format) blazer/queries#refresh +# tables_queries GET /queries/tables(.:format) blazer/queries#tables +# schema_queries GET /queries/schema(.:format) blazer/queries#schema +# docs_queries GET /queries/docs(.:format) blazer/queries#docs +# queries GET /queries(.:format) blazer/queries#index +# POST /queries(.:format) blazer/queries#create +# new_query GET /queries/new(.:format) blazer/queries#new +# edit_query GET /queries/:id/edit(.:format) blazer/queries#edit +# query GET /queries/:id(.:format) blazer/queries#show +# PATCH /queries/:id(.:format) blazer/queries#update +# PUT /queries/:id(.:format) blazer/queries#update +# DELETE /queries/:id(.:format) blazer/queries#destroy +# run_check GET /checks/:id/run(.:format) blazer/checks#run +# checks GET /checks(.:format) blazer/checks#index +# POST /checks(.:format) blazer/checks#create +# new_check GET /checks/new(.:format) blazer/checks#new +# edit_check GET /checks/:id/edit(.:format) blazer/checks#edit +# check PATCH /checks/:id(.:format) blazer/checks#update +# PUT /checks/:id(.:format) blazer/checks#update +# DELETE /checks/:id(.:format) blazer/checks#destroy +# refresh_dashboard POST /dashboards/:id/refresh(.:format) blazer/dashboards#refresh +# dashboards POST /dashboards(.:format) blazer/dashboards#create +# new_dashboard GET /dashboards/new(.:format) blazer/dashboards#new +# edit_dashboard GET /dashboards/:id/edit(.:format) blazer/dashboards#edit +# dashboard GET /dashboards/:id(.:format) blazer/dashboards#show +# PATCH /dashboards/:id(.:format) blazer/dashboards#update +# PUT /dashboards/:id(.:format) blazer/dashboards#update +# DELETE /dashboards/:id(.:format) blazer/dashboards#destroy +# root GET / blazer/queries#home +# +# Routes for Marksmith::Engine: +# Prefix Verb URI Pattern Controller#Action +# markdown_previews POST /markdown_previews(.:format) marksmith/markdown_previews#create + class AdminConstraint def self.matches?(request) return false unless request.session[:user_id] diff --git a/test/fixtures/shop_orders.yml b/test/fixtures/shop_orders.yml index e5aa0dc5..c14e31fa 100644 --- a/test/fixtures/shop_orders.yml +++ b/test/fixtures/shop_orders.yml @@ -18,7 +18,7 @@ # quantity :integer # rejected_at :datetime # rejection_reason :string -# state :integer default("pending"), not null +# state :integer default(0), not null # tracking_number :string # created_at :datetime not null # updated_at :datetime not null diff --git a/test/models/shop_order_test.rb b/test/models/shop_order_test.rb index 56588e08..aa62d267 100644 --- a/test/models/shop_order_test.rb +++ b/test/models/shop_order_test.rb @@ -16,7 +16,7 @@ # quantity :integer # rejected_at :datetime # rejection_reason :string -# state :integer default("pending"), not null +# state :integer default(0), not null # tracking_number :string # created_at :datetime not null # updated_at :datetime not null From 842dab7ed09900e03b1292c1a260685c47cdeae4 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 13 Oct 2026 09:09:37 -0400 Subject: [PATCH 4/4] Set guild signup role outside permit to satisfy brakeman --- app/controllers/guild_signups_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/guild_signups_controller.rb b/app/controllers/guild_signups_controller.rb index 3dcf6577..1bb04bd3 100644 --- a/app/controllers/guild_signups_controller.rb +++ b/app/controllers/guild_signups_controller.rb @@ -157,8 +157,8 @@ def create private def signup_params - permitted = params.require(:guild_signup).permit(:role, :name, :email, :country, :ideas, :attendee_activities) - permitted[:role] = "attendee" unless %w[organizer attendee].include?(permitted[:role]) + permitted = params.require(:guild_signup).permit(:name, :email, :country, :ideas, :attendee_activities) + permitted[:role] = params.dig(:guild_signup, :role) == "organizer" ? "organizer" : "attendee" permitted end