Files
Logan CusanoandClaude Opus 5.5 db4e326332 Discord: per-bot OAuth "Connect a Discord server" backend (server-26#225 S6)
An org owner links a server by inviting one radio bot through that bot's
own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute
nonce bound to org + user (stored only as its sha256); the server linked is
the one Discord's code exchange names, never the redirect query.

- routers/discord_oauth.py: GET /discord/oauth/options, POST /start,
  POST /complete. 404 while discord_oauth_redirect_uri is empty or no
  usable bot has a secret. Bot order: explicit token_id, the node system's
  preferred bot, the org's own bot, least-full pool bot under the cap.
- internal/discord_oauth.py: state mint/consume transaction (login-CSRF:
  state.uid must be the caller; any presentation burns it), code exchange,
  revoke, client-credentials secret check. Through discord_rest's shared
  client and breaker; secrets/codes/tokens never logged or in errors.
- discord_guild_link: one link transaction body (_apply_link) and one
  settle step that stops a server's streams when the org it acted for
  changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth
  all use it; the previous org is now read inside the link transaction.
- tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count,
  server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with
  Discord before storing it in bot_oauth/{id}, never echoes it.
- firestore.rules: explicit deny for bot_oauth and discord_oauth_states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:52:57 -04:00

124 lines
5.7 KiB
Python

"""
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}"
try:
# Links, then stops anything the server had playing for the org it
# acted for before (the DISCORD_UNLINKED_GUILD_ORG_ID fallback).
result = await glink.redeem_and_settle(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 = result["streams_stopped"]
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),
"via": "code",
})
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"]}