Add a moderator-gated /unlink <user> slash command (required_permissions = "MANAGE_ROLES", same gate as embed()). It deletes the dsec_discord_members row FIRST, then removes the verified role -- that order avoids leaving a member un-roled but still linked, the state that permanently breaks /member_info. The delete uses .returning(...) so PostgREST returns the row body (the COR-03 empty-204 gotcha) and so we can tell whether a link actually existed. It replies ephemerally with what it did and reports clearly if the role-removal half fails so a human can finish it, then writes a log_embed entry to the logs channel naming the acting moderator. Registered in main.rs. Add SECURITY.md: the moderator runbook for undoing a link (via /unlink and by hand), who holds the Supabase credentials, the fact that deleting the row does NOT revoke the Discord role (why /unlink does both), and the owner-only SEC-19 UNIQUE-constraint step. A member-facing /unverify is deliberately not added. The /member_info leave/trim/remove policy call is an owner decision and is intentionally not implemented here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
3.2 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:
select * from dsec_discord_members where student_id = 's123456789';
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)
dsec_discord_members.student_id should have a UNIQUE constraint so one student id
cannot be claimed by two Discord accounts. The application already refuses the second
claimant, but the durable fix is the constraint. It 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.