dsec-discord-bot/SECURITY.md
Clupai8o0 327a4962d1 COL-BOT-01: /unlink edits its deferred reply + disambiguates delete failures
#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
2026-08-30 18:08:37 +10:00

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).

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.

Run /unlink @member in the server. It requires the Manage Roles permission and:

  1. deletes the member's dsec_discord_members row, then
  2. removes the verified role from them,
  3. replies to you privately (ephemeral) with what it did, and
  4. 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.

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.