Skip to content

Commit ce2be3c

Browse files
authored
Narrow what the Limited event role can see (#107)
Limited already worked without exact dates of birth or addresses. It now also works without phone numbers, and without the contact details of the people around a participant. - Every phone number is hidden: participant profile and card, the table column, the edit form (and its strong params), the scanner modal and its lookup JSON, SMS rows in message delivery logs, the phone export column, the mobile API payload, and the MCP participant tools. - Guardian email and phone are hidden on the profile and in the API. The guardian edit form and "link guardian" are closed to the role outright, since both are nothing but contact details -- and the guardians controller had no authorization check at all before this. - Emergency contacts now appear on the participant profile: first name and phone number for Limited, so an incident can be escalated, and the full record for roles that already had it on the safeguarding tab. Nothing new for Ops. - The support inbox is closed to the role: a ticket is an SMS or email thread keyed by the sender's number or address, so there is no redacted version of it to show. - The change history hides phone changes, and hides email changes on guardian and emergency-contact records while keeping the participant's own. An attendee's own email address is deliberately untouched -- the role searches and works from it every day.
1 parent 8747618 commit ce2be3c

20 files changed

Lines changed: 466 additions & 73 deletions

app/controllers/admin/guardians_controller.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ class GuardiansController < BaseController
33
before_action :require_event_selected
44
before_action :set_participant_event
55
before_action :set_guardian_participant_event
6+
# The form is nothing but a guardian's contact details and address, none of
7+
# which PII-restricted roles may see, so they can't open or submit it.
8+
before_action :require_contact_details_access
69

710
def edit
811
@guardian = @guardian_participant_event.guardian
@@ -37,6 +40,13 @@ def update
3740

3841
private
3942

43+
def require_contact_details_access
44+
return if can_view_participant_pii?
45+
46+
redirect_to admin_event_participant_path(current_event, @participant_event),
47+
alert: "Your role cannot edit guardian details, because it cannot see their contact details."
48+
end
49+
4050
def set_participant_event
4151
@participant_event = current_event.participant_events.find(params[:participant_id])
4252
end

app/controllers/admin/participants_controller.rb

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,21 @@ def show
157157
@guardian_participant_events = @participant_event.guardian_participant_events.includes(:guardian)
158158
@notes = @participant_event.notes.includes(:author).order(created_at: :desc)
159159
@scans = @participant_event.scans.includes(:user, :scan_context).recent.limit(3)
160+
@emergency_contacts = emergency_contacts_for_profile
160161
end
161162

162163
def link_guardian
163164
authorize @participant_event, :update?
164165

166+
# A guardian's contact details stay hidden from PII-restricted roles, so
167+
# they have no business writing them either. The form is hidden for them;
168+
# this stops a hand-crafted POST.
169+
unless can_view_participant_pii?
170+
redirect_to admin_event_participant_path(current_event, @participant_event),
171+
alert: "Your role cannot add guardians, because it cannot see their contact details."
172+
return
173+
end
174+
165175
guardian_email = params[:guardian_email].to_s.strip.downcase
166176
participant_email = @participant_event.participant.email.to_s.strip.downcase
167177

@@ -937,6 +947,20 @@ def set_participant_event
937947
@participant_event = current_event.participant_events.find(params[:id])
938948
end
939949

950+
# Emergency contacts for the profile page. Safeguarding viewers see them in
951+
# full (the same list the safeguarding tab shows); PII-restricted roles get
952+
# a first name and a phone number, because running an incident means being
953+
# able to call someone. Every other role sees no section at all, which is
954+
# what they saw before this block existed.
955+
def emergency_contacts_for_profile
956+
return nil unless !can_view_participant_pii? || policy(@participant_event).view_safeguarding?
957+
958+
EmergencyContact.left_joins(:guardian_participant_event).where(
959+
"emergency_contacts.participant_event_id = :pe_id OR guardian_participant_events.participant_event_id = :pe_id",
960+
pe_id: @participant_event.id
961+
).by_priority
962+
end
963+
940964
def set_participant_header_data
941965
@participant = @participant_event.participant
942966
@safeguarding_info = @participant_event.safeguarding_info
@@ -948,10 +972,11 @@ def participant_params
948972
:legal_first_name, :legal_last_name, :preferred_name, :email,
949973
:date_of_birth, :phone, :pronouns, :tshirt_size, :headshot
950974
]
951-
# The edit form hides the field for PII-restricted roles; dropping it here
952-
# too means a hand-crafted POST can't write (or probe) a date of birth
953-
# the same user isn't allowed to read back.
954-
permitted.delete(:date_of_birth) unless can_view_participant_pii?
975+
# The edit form hides these fields for PII-restricted roles; dropping them
976+
# here too means a hand-crafted POST can't write (or probe) a date of
977+
# birth or phone number the same user isn't allowed to read back. Email
978+
# stays: those roles can see and search on a participant's address.
979+
permitted -= [ :date_of_birth, :phone ] unless can_view_participant_pii?
955980

956981
params.require(:participant).permit(*permitted)
957982
end

app/controllers/admin/scans_controller.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ def create
177177
display_name: participant.display_name,
178178
full_name: participant.full_name,
179179
email: participant.email,
180-
phone: participant.phone,
180+
# The phone is omitted, not blanked, for PII-restricted roles; the
181+
# scanner modal hides that row entirely for them.
182+
**(can_view_participant_pii? ? { phone: participant.phone } : {}),
181183
pronouns: participant.pronouns,
182184
tshirt_size: participant.tshirt_size,
183185
status: participant_event.status,
@@ -210,6 +212,13 @@ def history
210212
)
211213
end
212214

215+
# The participant profile links here for one person's scans. It filters by
216+
# id rather than putting their email in the URL, which roles that can't
217+
# see contact details must not be handed.
218+
if params[:participant_event_id].present?
219+
@scans = @scans.where(participant_event_id: params[:participant_event_id])
220+
end
221+
213222
if params[:scan_context_id].present?
214223
@scans = @scans.where(scan_context_id: params[:scan_context_id])
215224
end

app/controllers/api/v1/participants_controller.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,10 @@ def participant_json(pe, detailed: false)
217217
participant_event_id: pe.id,
218218
display_name: participant.display_name,
219219
full_name: participant.full_name,
220-
# Contact details are omitted, not nulled, for PII-restricted roles —
221-
# same contract as date_of_birth and address in #personal_json.
222-
**(include_pii? ? { email: participant.email, phone: participant.phone } : {}),
220+
email: participant.email,
221+
# The phone is omitted, not nulled, for PII-restricted roles — same
222+
# contract as date_of_birth and address in #personal_json.
223+
**(include_pii? ? { phone: participant.phone } : {}),
223224
slack_user_id: participant.slack_user_id,
224225
pronouns: participant.pronouns,
225226
headshot_url: headshot_url_for(participant),
@@ -528,8 +529,9 @@ def travel_leg_json(leg)
528529
}
529530
end
530531

531-
# Whether this caller gets exact dates of birth, addresses, and contact
532-
# details (their own and their guardians'). Memoized
532+
# Whether this caller gets exact dates of birth, addresses, phone numbers,
533+
# and the people around a participant (guardian and emergency contact
534+
# details). A participant's own email address is not gated. Memoized
533535
# for the same reason as can_view_sensitive_data? below. A nil
534536
# current_user means an event API key, which never reaches the actions
535537
# that serve these fields (see API_KEY_ALLOWED_ACTIONS).

app/helpers/admin/audit_logs_helper.rb

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@ module Admin
22
module AuditLogsHelper
33
SENSITIVE_FIELD_PATTERNS = /password|token|secret|api_key|otp|access_key/i
44

5-
# Columns holding an exact date of birth or a home address, on participants
6-
# and guardians alike. PII-restricted roles (see
7-
# EventRoleAssignment::PII_RESTRICTED_ROLES) can reach a participant's change
8-
# history, and a changeset is just as revealing as the field itself.
5+
# Columns holding an exact date of birth, a home address, or a phone number,
6+
# on participants, guardians, and emergency contacts alike. PII-restricted
7+
# roles (see EventRoleAssignment::PII_RESTRICTED_ROLES) can reach a
8+
# participant's change history, and a changeset is just as revealing as the
9+
# field itself.
910
PII_FIELDS = %w[
1011
date_of_birth address_line_1 address_line_2 city state postal_code
1112
country country_of_residence
13+
phone phone_number phone_override
1214
].freeze
1315

16+
# The history page mixes in Guardian and EmergencyContact versions. A
17+
# participant's own email address is visible to PII-restricted roles; the
18+
# people around them still aren't.
19+
EMAIL_FIELDS = %w[email invited_via_email].freeze
20+
1421
def audit_field_label(field)
1522
field.to_s.humanize
1623
end
@@ -29,13 +36,18 @@ def audit_changed_field_pairs(changed_fields)
2936

3037
# Returns a hash of {field => [old, new]} from a PaperTrail::Version.
3138
# Hides framework noise so the diff stays human-readable. Pass
32-
# `hide_pii: true` to also drop dates of birth and addresses.
39+
# `hide_pii: true` to also drop dates of birth, addresses, phone numbers,
40+
# and everyone-but-the-participant's email.
3341
def audit_version_changes(version, hide_pii: false)
3442
raw = (version.respond_to?(:changeset) ? version.changeset : nil) || {}
3543
cleaned = raw.except("updated_at", "created_at", "encrypted_password", "remember_created_at",
3644
"current_sign_in_at", "last_sign_in_at", "current_sign_in_ip", "last_sign_in_ip",
3745
"sign_in_count", "oidc_claims")
38-
hide_pii ? cleaned.except(*PII_FIELDS) : cleaned
46+
return cleaned unless hide_pii
47+
48+
hidden = PII_FIELDS
49+
hidden += EMAIL_FIELDS unless version.item_type == "Participant"
50+
cleaned.except(*hidden)
3951
rescue => e
4052
Rails.logger.warn("[AuditLog] Could not parse version #{version.id}: #{e.message}")
4153
{}

app/helpers/admin/participants_helper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ def render_blocking_step_badge(participant_event)
7676
content_tag(:span, label, class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-50 text-yellow-700 border border-yellow-200")
7777
end
7878

79+
# Placeholder for a contact detail the current role isn't allowed to see (see
80+
# EventRoleAssignment::PII_RESTRICTED_ROLES). Every surface hides it the same
81+
# way, and the title says why so it doesn't read as missing data.
82+
def contact_hidden_placeholder(label = "Hidden")
83+
content_tag(:span, label, class: "italic text-gray-400",
84+
title: "Phone numbers and guardian contact details are hidden for your role")
85+
end
86+
7987
def render_sort_icon(field, current_sort, current_direction)
8088
return "" unless current_sort == field
8189

app/models/emergency_contact.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ def participant
2727
participant_event&.participant || guardian_participant_event&.participant
2828
end
2929

30+
# `name` is one free-form field, so the first word is the best we can do for a
31+
# first name. Roles that can't see contact details still get this much plus
32+
# the phone number, so they can call the contact during an incident.
33+
def first_name
34+
name.to_s.split.first.presence || name
35+
end
36+
3037
private
3138

3239
def linked_to_guardian_or_participant

app/models/event_role_assignment.rb

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ class EventRoleAssignment < ApplicationRecord
1515
}
1616

1717
# Roles that do the job without seeing a participant's most identifying
18-
# details: they get age instead of an exact date of birth, and no address of
19-
# any kind — not the home address, not the travel pickup address. Medical
20-
# records are deliberately NOT restricted; someone helping with a medical
21-
# incident needs the conditions and medications. Enforced through
18+
# details: they get age instead of an exact date of birth, no address of any
19+
# kind — not the home address, not the travel pickup address — and no phone
20+
# numbers, plus nothing at all for the people around a participant (guardian
21+
# and emergency contact contact details). The exceptions are the
22+
# participant's own email address, which these roles search and work from
23+
# every day, and an emergency contact's first name and phone number, which
24+
# someone running an incident has to be able to dial. Medical records are
25+
# deliberately NOT restricted for the same reason. Enforced through
2226
# User#can_view_participant_pii?, which every surface that renders or exports
2327
# those fields checks.
2428
PII_RESTRICTED_ROLES = %w[limited].freeze
@@ -56,17 +60,22 @@ class EventRoleAssignment < ApplicationRecord
5660
},
5761
"limited" => {
5862
label: "Limited",
59-
summary: "Day-to-day logistics, without addresses or exact birthdays.",
63+
summary: "Day-to-day logistics, without phone numbers, addresses, or exact birthdays.",
6064
can: [
6165
"View and edit travel and accommodation",
6266
"Manage groups and rooming",
6367
"View full medical records, so they can help in an incident",
6468
"View consents and notes",
65-
"See age at the event, instead of a date of birth"
69+
"See age at the event, instead of a date of birth",
70+
"See attendee email addresses, and search by them",
71+
"See the first name and phone number of an emergency contact"
6672
],
6773
cannot: [
6874
"See exact dates of birth",
6975
"See any address, at home or for travel pickup",
76+
"See phone numbers, other than an emergency contact one",
77+
"See a guardian email address, or edit guardian details",
78+
"Work the support inbox",
7079
"Manage staff",
7180
"Add or remove participants",
7281
"Access safeguarding records"

app/models/user.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ class User < ApplicationRecord
1414

1515
enum :global_role, { no_role: "none", global_admin: "global_admin", read_only: "read_only" }, default: :no_role
1616

17-
# Event roles whose day-to-day work includes the support inbox.
18-
SUPPORT_ROLES = %w[event_admin ops limited].freeze
17+
# Event roles whose day-to-day work includes the support inbox. Limited is
18+
# deliberately absent: an SMS ticket is keyed by the sender's phone number,
19+
# and threads carry guardians' contact details too, neither of which that
20+
# role may see (see EventRoleAssignment::PII_RESTRICTED_ROLES).
21+
SUPPORT_ROLES = %w[event_admin ops].freeze
1922

2023
# `oidc_claims` holds PII straight from Hack Club Auth (phone number,
2124
# birthdate, home address). Encrypting the whole jsonb blob works because the
@@ -334,8 +337,10 @@ def admin?
334337
series_role_assignments.exists?
335338
end
336339

337-
# Whether this user may see participants' exact dates of birth and home
338-
# addresses. False only for someone whose access comes entirely from
340+
# Whether this user may see participants' identifying details: exact dates of
341+
# birth, home addresses, phone numbers, and the contact details of the people
342+
# around them (guardians and emergency contacts). A participant's own email
343+
# address is not gated. False only for someone whose access comes entirely from
339344
# PII-restricted roles (see EventRoleAssignment::PII_RESTRICTED_ROLES) —
340345
# holding any other role, on the event in question or anywhere when no event
341346
# is given, restores the full view. Fails closed for a user with no roles.

app/services/exports/field_registry.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class FieldRegistry
1717
CATEGORIES = {
1818
"identity" => { label: "Identity", tier: :identity, description: "Participant name and email" },
1919
"basic" => { label: "Participant", tier: :general, description: "Contact details, address, and profile information",
20-
pii_restricted_description: "Contact details and profile information" },
20+
pii_restricted_description: "Profile information" },
2121
"event_status" => { label: "Event Status", tier: :general, description: "Registration status, check-in, and onboarding progress" },
2222
"travel" => { label: "Travel", tier: :general, description: "Inbound and outbound travel details" },
2323
"flight_legs" => { label: "Flight Legs", tier: :general, description: "Per-leg flight details (only in one-row-per-flight-leg mode)" },
@@ -44,8 +44,11 @@ class FieldRegistry
4444
# EventRoleAssignment::PII_RESTRICTED_ROLES). "Age at Event Start" stays, so
4545
# a Limited exporter can still do the rooming and minor-count work that
4646
# needs an age. The travel origin addresses are here too: for a car
47-
# journey that field is the participant's doorstep.
47+
# journey that field is the participant's doorstep. The phone number goes
48+
# with them; a participant's email address does not, since those roles work
49+
# from it every day.
4850
PII_RESTRICTED_FIELD_KEYS = %w[
51+
participant.phone
4952
participant.date_of_birth
5053
participant.address_line_1
5154
participant.address_line_2

0 commit comments

Comments
 (0)