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>
124 lines
5.7 KiB
Python
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"]}
|