Address adversarial-review findings on the verify path:
- Already-linked caller bypass (HIGH): add_dsec_discord_table no longer early-returns
Linked on any existing row. It now checks the SUBMITTED id's owner first (idempotent
only when that owner is the caller) and refuses a caller already linked to a DIFFERENT
student id -- closing the path where a stale COR-03 partial-insert row let an account
claim someone else's id.
- Check-then-insert race (HIGH): the insert now catches a UNIQUE(student_id) violation
(SQLSTATE 23505), re-queries the owner and converts it to the generic refusal + audit.
SECURITY.md now marks the live UNIQUE constraint (after the dup sweep) as a merge/deploy
gate, not optional.
- PII leak in error prints (HIGH): raw supabase/reqwest errors (which embed
student_id=eq.<id> and a 23505 Key detail) are never printed on the verify path. New
redact_digits() masks 7+ digit runs; the interaction id is used as an opaque
correlation ref. student_id_owner now selects only discord_id.
- Concurrent-attempt rate-limit bypass (HIGH): a per-UserId tokio Mutex (AppState.
verify_locks) serializes a whole verification attempt so concurrent modal submits
cannot each slip under the 5-in-15min limit. Requires tokio "sync" feature.
- Ownership conflict never counted (MED): a refused link now records exactly one failed
attempt (success/infra-error record none), so a stolen-but-claimed credential can no
longer loop the query set forever.
- Expired-interaction mutate-then-skip (MED): defer_ephemeral is now sent the instant the
modal arrives, before any DB/logs work; every later reply edits the deferred response.
- Log oracle (MED): the failed-attempt audit line is one fixed generic string, so
"not found" vs "name mismatch" are indistinguishable in the logs channel too.
Also: on_error no longer forwards a handled FrameworkError::Command to poise's builtin
(whose Command arm does a non-ephemeral ctx.say(raw_error), leaking DB error text); mutex
locks recover from poisoning instead of panicking; and the attempt/lock maps evict
stale/idle entries so they cannot grow unbounded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
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