#7b — one coherent reply: /unlink now takes ApplicationContext, defers via defer_response(true), and EDITs that deferred ephemeral (create/edit at the serenity layer). Previously ctx.send after a defer posted a followup in this poise version, leaving the "thinking…" placeholder dangling. Defer failure now aborts before any DB op (mirrors the verify fix). new #A — no false "nothing changed": a DELETE whose HTTP call errors may still have committed server-side. On a delete error /unlink now reads the row back: row present -> genuinely failed, nothing changed; row gone -> the delete took effect, proceed to role removal; read-back also fails -> report status UNKNOWN and do NOT touch the role, telling the moderator to verify by hand. It never asserts "no change" on a bare error. new #B — runbook SQL corrected: the bot stores student ids as digits only (strips the leading s and punctuation), so the find-link query now searches '123456789', not 's123456789', with a note on the stored format. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
3.8 KiB
Security runbook
Operational notes for moderators and maintainers. This file is for the people who
run the bot, not for contributors setting up a dev environment (that is README.md).
Undoing a verification link (/unlink)
The bot links a Discord account to a student id in the dsec_discord_members table
when someone verifies. Because verification only checks a name + student id pair —
both semi-public — a person can verify as someone else. When that happens, undo it.
Preferred: the /unlink command
Run /unlink @member in the server. It requires the Manage Roles permission and:
- deletes the member's
dsec_discord_membersrow, then - removes the verified role from them,
- replies to you privately (ephemeral) with what it did, and
- writes a log entry to the logs channel naming you as the moderator who ran it.
It does the delete first and the role removal second on purpose: the reverse order
can leave someone un-roled but still linked, which permanently breaks /member_info
for them. If the role removal half fails, the reply and the log both say so — finish it
by hand (next section) and the row is already gone.
Manual removal (when /unlink cannot be used)
You need the Supabase project credentials for this. Who holds them: the club committee — ask in the committee Discord. (Historically the bot maintainer; TODO: name the current holder here.) Do not paste real credentials into a chat or a ticket.
Find the link row(s) for a student id. The bot stores the id as digits only (it
strips the leading s and any punctuation before saving), so search the bare digits —
'123456789', not 's123456789':
select * from dsec_discord_members where student_id = '123456789';
Delete a specific link by hand:
delete from dsec_discord_members where discord_id = '<discord user id>';
Deleting the row does NOT revoke the Discord role. Removing the row and stripping
the verified role are two separate actions — that is exactly why /unlink does both.
After a manual delete, also remove the verified role from the member in Discord (Server
Settings → Members, or right-click the member → Roles), or they keep their access with
no link.
Owner-only database step (SEC-19) — MERGE / DEPLOY GATE
This is a deploy gate, not optional. dsec_discord_members.student_id MUST have a
UNIQUE constraint so one student id cannot be claimed by two Discord accounts. Run
the duplicate sweep and add the constraint (below) as part of shipping this change.
The application also refuses the second claimant and catches a concurrent-insert unique violation (SQLSTATE 23505), converting it to a generic refusal — but the check-then- insert has an inherent race, so the database constraint is what actually guarantees uniqueness. Until the constraint exists, two accounts verifying the same unused id at the exact same moment can both succeed. The constraint is not applied by any migration in this repo — it touches live data and must be run by a maintainer.
Sweep for existing duplicates first; the DDL fails if any exist:
select student_id, count(*) from dsec_discord_members
group by student_id having count(*) > 1;
Resolve any duplicates by hand (decide which Discord account keeps each link), then:
alter table dsec_discord_members add constraint dsec_discord_members_student_id_key unique (student_id);
There is no staging Supabase project — do the sweep and the ALTER with a second
maintainer watching. Expect it to start rejecting inserts that used to succeed.
The real fix for the weak identity check is a possession proof — email a one-time code to the roster address (dsec-app already owns OTP machinery; the bot would call dsec-api). That is feature-sized work tracked under SEC-19, not covered here.