feat(discord): self-serve server->org linking with /drb link (#213 item 1)

- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
  10-minute code; minting revokes the org's older codes. The bot redeems it
  via POST /discord/guilds/{id}/link/redeem (service key) inside one
  Firestore transaction, so two servers racing for one code cannot both win
  and a server linked to org A refuses org B's code. GET/DELETE
  /discord/linked-guilds lists/unlinks the caller's org's servers; unlink
  stops that server's streams. Rate-limited (mint per user, redeem per
  server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
  the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
  GatedTree answers every other command in an unlinked server with how to
  link (autocomplete returns nothing). With the default fallback org set,
  nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
  generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
  revoked/malformed codes, wrong-org takeover, idempotent relink, rate
  limits, fallback stop, scoped list, unlink without fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 20:01:25 -04:00
co-authored by Claude Opus 5.5
parent dc1df72c86
commit 455c55f5c2
14 changed files with 960 additions and 3 deletions
+5 -1
View File
@@ -249,7 +249,11 @@ class Settings(BaseSettings):
discord_lease_offline_release_seconds: int = 600
# Org a Discord server that was never linked to an org (discord_guilds/{id})
# acts for. Default keeps today's single-org deployment working unchanged;
# set it to "" to require every server to be linked first.
# set it to "" to require every server to be linked first. Do that once
# every server that should keep working has been linked (/drb link, or
# PUT /discord/guilds/{id}/link) — unlinked servers then get only
# /drb link and /help (server-26#213). A server an org explicitly unlinked
# never falls back to this org either way.
discord_unlinked_guild_org_id: str = "founding"
class Config:
@@ -0,0 +1,169 @@
"""
Self-serve Discord server -> DRB org linking (server-26#213 item 1).
Same shape as the account-link codes in routers/links.py (web mints a short
code, the Discord bot redeems it), with the gaps that pattern has closed:
* **Single-use under a race.** Redeem runs in one Firestore transaction that
reads the code doc and the guild doc before writing either, so two servers
redeeming one code at once cannot both win — the loser's transaction is
re-run, sees ``redeemed_at`` set, and is refused.
* **Short TTL** (``CODE_TTL_MINUTES``) and **one live code per org**: minting
a new code revokes the org's older unredeemed ones.
* **Never moves a server between orgs.** A server already linked to org A
refuses org B's code (409) until org A unlinks it from its own settings.
* An explicitly unlinked server does **not** fall back to
``DISCORD_UNLINKED_GUILD_ORG_ID`` (see ``discord_streams.resolve_guild_org``)
— otherwise unlinking org B's server would hand it to the founding org.
Firestore layout:
discord_link_codes/{CODE} CODE = 10 chars, no dashes, upper-case
org_id, created_by_uid, created_at, expires_at,
redeemed_at|None, redeemed_guild_id|None, revoked: bool
discord_guilds/{guild_id} (shared with discord_streams.py)
guild_id, org_id|None, guild_name, linked_by, linked_via, linked_at,
unlinked_at|None, unlinked_by|None
Person names are never stored: the redeemer is kept as a Discord user id.
"""
from __future__ import annotations
import re
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from app.internal import firestore as fstore
CODES = "discord_link_codes"
GUILDS = "discord_guilds"
CODE_TTL_MINUTES = 10
# Unambiguous alphabet: no 0/O, 1/I/L. 31^10 ~= 8e14 codes.
_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
_CODE_LEN = 10
class LinkError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(message)
self.status, self.code, self.message = status, code, message
def _now() -> datetime:
return datetime.now(timezone.utc)
def new_code() -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(_CODE_LEN))
def normalize(code: str) -> str:
"""Accept what a person pastes: any case, with or without dashes/spaces."""
return re.sub(r"[^A-Za-z0-9]", "", code or "").upper()
def display(code: str) -> str:
"""RVR7Q4K22X -> RVR-7Q4K-22X (the mockup's grouping, for reading aloud)."""
return f"{code[:3]}-{code[3:7]}-{code[7:]}"
async def mint_code(org_id: str, uid: str) -> dict:
"""Create a code for ``org_id`` and revoke the org's older unredeemed ones."""
for old in await fstore.collection_list(CODES, org_id=org_id):
if not old.get("redeemed_at") and not old.get("revoked") and old.get("code"):
await fstore.doc_update(CODES, old["code"], {"revoked": True})
code = new_code()
now = _now()
expires_at = now + timedelta(minutes=CODE_TTL_MINUTES)
await fstore.doc_set(CODES, code, {
"code": code,
"org_id": org_id,
"created_by_uid": uid,
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
"redeemed_at": None,
"redeemed_guild_id": None,
"revoked": False,
}, merge=False)
return {"code": display(code), "expires_at": expires_at.isoformat(),
"expires_minutes": CODE_TTL_MINUTES}
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
"""Build the transaction body. Every read happens before any write."""
db = fstore.db
def _txn(txn):
code_ref = db.collection(CODES).document(code)
guild_ref = db.collection(GUILDS).document(guild_id)
code_snap = code_ref.get(transaction=txn)
guild_snap = guild_ref.get(transaction=txn)
doc = code_snap.to_dict() if code_snap.exists else None
guild = guild_snap.to_dict() if guild_snap.exists else None
if not doc or doc.get("revoked"):
return {"error": LinkError(404, "invalid_code",
"That link code is not valid. Generate a new one in DRB → "
"Settings → Organization.")}
if doc.get("redeemed_at"):
return {"error": LinkError(409, "code_used", "That link code has already been used.")}
if now > datetime.fromisoformat(doc["expires_at"]):
return {"error": LinkError(410, "code_expired",
"That link code has expired. Generate a new one in DRB → "
"Settings → Organization.")}
org_id = doc["org_id"]
current = (guild or {}).get("org_id")
if current and current != org_id:
return {"error": LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")}
iso = now.isoformat()
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
"redeemed_by_discord_id": discord_user_id})
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
"linked_by": f"discord:{discord_user_id}",
"linked_via": "code",
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"created_by_uid": doc.get("created_by_uid")}
return _txn
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal."""
code = normalize(raw_code)
if len(code) != _CODE_LEN:
raise LinkError(404, "invalid_code",
"That link code is not valid. Generate a new one in DRB → Settings → Organization.")
result = await fstore.run_transaction(
_redeem_txn(code, str(guild_id), guild_name, str(discord_user_id), _now()))
if "error" in result:
raise result["error"]
return result
async def list_org_guilds(org_id: str) -> list[dict]:
return [
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
"linked_at": g.get("linked_at"), "linked_via": g.get("linked_via") or "admin"}
for g in await fstore.collection_list(GUILDS, org_id=org_id)
]
async def unlink(org_id: str, guild_id: str, uid: str) -> None:
guild = await fstore.doc_get(GUILDS, guild_id)
if not guild or guild.get("org_id") != org_id:
raise LinkError(404, "not_linked", "That Discord server is not linked to this organization.")
await fstore.doc_set(GUILDS, guild_id, {
"org_id": None, "unlinked_at": _now().isoformat(), "unlinked_by": uid,
}, merge=True)
+7 -1
View File
@@ -75,10 +75,16 @@ def _parse_ts(value) -> Optional[datetime]:
async def resolve_guild_org(guild_id: str) -> Optional[str]:
"""The org a Discord server acts for: its ``discord_guilds`` link, else
``settings.discord_unlinked_guild_org_id`` (None when that is empty)."""
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
gets no fallback: otherwise unlinking org B's server would quietly hand it
to the founding org."""
link = await fstore.doc_get(GUILDS, str(guild_id))
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
+2
View File
@@ -20,6 +20,7 @@ from app.internal.auth import (
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
from app.routers import discord as discord_router
from app.routers import discord_link as discord_link_router
from app.internal import dynsec
from app.internal import firestore as fstore
@@ -158,6 +159,7 @@ app.include_router(media.router)
# auth is per-endpoint: bot routes take the service key (or a platform admin);
# the token-fetch route takes the calling node's own API key.
app.include_router(discord_router.router)
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
# is archived (no CVE patches) and was rejected for an internet-facing
+125
View File
@@ -0,0 +1,125 @@
"""
Self-serve Discord server -> org linking (server-26#213 item 1).
POST /discord/link-codes org owner / platform admin — mint a code
GET /discord/linked-guilds org owner / platform admin — this org's servers
DELETE /discord/linked-guilds/{guild_id} org owner / platform admin — unlink one
POST /discord/guilds/{guild_id}/link/redeem Discord bot (service key) — /drb link code:
Platform admins may pass ?org_id= to act on another org (resolve_org_scope),
same as the rest of the org-scoped API. The admin-only
``PUT /discord/guilds/{id}/link`` in routers/discord.py stays for support.
Every mint, link, refusal and unlink is written to audit_log. Rate limits are
the in-process sliding-window limiter from internal/auth.py (single c2-core
instance; not shared across replicas).
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
from app.internal.audit import write_audit
from app.internal.auth import (
_RateLimiter, describe_actor, require_org_owner_token, require_service_key, resolve_org_scope,
)
from app.internal.logger import logger
router = APIRouter(prefix="/discord", tags=["discord"])
# Minting: 5 codes per user per 10 min — each mint revokes the previous one,
# so this only stops a runaway client.
mint_limiter = _RateLimiter(max_calls=5, window_seconds=600)
# Redeeming: 5 attempts per server and 10 per Discord user per 10 min. With a
# 10-character code over 31 symbols and a 10-minute TTL, guessing is hopeless
# at this rate even across many throwaway servers.
redeem_guild_limiter = _RateLimiter(max_calls=5, window_seconds=600)
redeem_user_limiter = _RateLimiter(max_calls=10, window_seconds=600)
def _caller_org(decoded: dict, org_id: Optional[str]) -> str:
return resolve_org_scope(decoded, org_id)
@router.post("/link-codes")
async def create_link_code(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)):
"""Mint a single-use code an org owner pastes into ``/drb link code:``."""
scope = _caller_org(decoded, org_id)
uid, email = describe_actor(decoded)
mint_limiter.check(f"{uid}:discord_link_code")
out = await glink.mint_code(scope, uid)
await write_audit(uid, email, "discord.link_code.create",
details={"org_id": scope, "expires_at": out["expires_at"]})
logger.info(f"Discord link code minted for org={scope!r} by uid={uid}")
return {**out, "command": f"/drb link code:{out['code']}"}
@router.get("/linked-guilds")
async def linked_guilds(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)):
scope = _caller_org(decoded, org_id)
return {
"guilds": await glink.list_org_guilds(scope),
# False while DISCORD_UNLINKED_GUILD_ORG_ID is set: unlinked servers
# then still act for that org instead of seeing only /drb link + /help.
"unlinked_guilds_restricted": not settings.discord_unlinked_guild_org_id,
}
@router.delete("/linked-guilds/{guild_id}")
async def unlink_guild(guild_id: str, org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token)):
scope = _caller_org(decoded, org_id)
uid, email = describe_actor(decoded)
try:
await glink.unlink(scope, guild_id, uid)
except glink.LinkError as e:
raise HTTPException(e.status, e.message)
# Its radio channels were playing for this org; they go with the link.
stopped = await streams.stop_guild(guild_id, "unlinked")
await write_audit(uid, email, "discord.guild.unlink",
details={"org_id": scope, "guild_id": guild_id, "streams_stopped": len(stopped)})
return {"ok": True, "guild_id": guild_id, "streams_stopped": len(stopped)}
class RedeemBody(BaseModel):
code: str
discord_user_id: str
guild_name: Optional[str] = None
@router.post("/guilds/{guild_id}/link/redeem")
async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = Depends(require_service_key)):
"""The Discord bot redeems a code for the server it was typed in. The bot
has already checked the member holds Manage Server."""
redeem_guild_limiter.check(f"guild:{guild_id}")
redeem_user_limiter.check(f"user:{body.discord_user_id}")
actor = f"discord:{body.discord_user_id}"
before = await streams.resolve_guild_org(guild_id)
try:
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id)
except glink.LinkError as e:
await write_audit(actor, "", "discord.guild.link_refused",
details={"guild_id": guild_id, "reason": e.code})
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
org_id = result["org_id"]
stopped: list = []
if before and before != org_id:
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
# had playing belongs to that org, not the one it just linked to.
stopped = await streams.stop_guild(guild_id, "relinked")
await write_audit(actor, "", "discord.guild.link", details={
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
})
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
org = await fstore.doc_get("organizations", org_id)
return {"ok": True, "guild_id": guild_id, "org_id": org_id,
"org_name": (org or {}).get("name"), "already_linked": result["already_linked"]}