Discord: check bot permissions before handing a token to a node (server-26#225 S3)
start_stream now asks Discord (cached REST snapshots, S2) whether each bot
it could pick may View, Connect and Speak in the voice channel, before the
lease. Covers all four entry points: bot POST /discord/streams, web picker,
nodes discord_join, and the reconciler's re-place.
- pick_token: denied bots skipped; confirmed-ok sorts before unknown;
unknown is allowed (fail open); all denied -> missing_permissions naming
each bot, missing permission, trace reason and fix. The reuse branch
falls through when its token is denied.
- Bounded: discord_perm_check_concurrency at once, within
discord_perm_check_budget_seconds; unanswered = unknown.
- Non-voice channel -> 400 bad_channel (not missing_permissions).
- systems/{id}.preferred_token_id is now read (plan finding 1), as a hint.
- Pool bots at discord_pool_bot_guild_cap are never offered as invites;
pool_full when nothing else is left (owner Q3).
- handle_actual drops cached access on missing_permissions, channel_full,
not_in_guild, bad_channel.
- New GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check (org
member, names only) and GET /discord/guilds/{guild_id}/voice-check
(service key or admin).
- PUT /tokens/{id}/prefer/{system_id}: org owners for their own systems
and own BYO bots; shared systems and pool bots stay admin-only (owner Q2).
Keeps both pointers single-valued; audited.
- Kill switch: DISCORD_PERM_CHECK_ENABLED=false.
- tests/conftest.py: autouse fixture so no test ever reaches discord.com.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a75149b2eb
commit
2c2cf9d9be
@@ -93,7 +93,9 @@ async def guild_overview(guild_id: str, _: dict = Depends(require_service_key_or
|
||||
"name": t.get("name"),
|
||||
"busy_here": any(leases.guild_of(s) == guild_id for s in lease.get("streams") or []),
|
||||
"member": None if member is None else guild_id in member,
|
||||
"invite_url": leases.invite_url(t.get("application_id"), guild_id),
|
||||
# A pool bot at discord_pool_bot_guild_cap is never offered (#225, owner Q3).
|
||||
"invite_url": (leases.invite_url(t.get("application_id"), guild_id)
|
||||
if member or leases.invitable(t) else None),
|
||||
})
|
||||
return {
|
||||
"guild_id": guild_id,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Discord voice-channel permission status for the picker (server-26#225 S3).
|
||||
|
||||
Read-only. For one Discord server and its voice channels, says for each radio
|
||||
bot that could serve it whether Discord lets that bot View, Connect and Speak
|
||||
there, and when not, which permission is missing, what removed it and what to
|
||||
change. Built on the same cached REST snapshots ``start_stream`` checks before
|
||||
it hands a bot to a node (discord_streams.gather_snapshots / evaluate_access),
|
||||
so the badge the UI shows and the refusal a start gets agree.
|
||||
|
||||
Auth:
|
||||
* GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check
|
||||
any signed-in member of the node's org, or a platform admin
|
||||
(``discord_web._node_for_caller``); the server must be one of the org's
|
||||
(``discord_web._org_guilds``). Anything else is a 404. Bot NAMES only —
|
||||
no token ids for non-admins, never a token string.
|
||||
* GET /discord/guilds/{guild_id}/voice-check
|
||||
the Discord bot's service key or a platform admin; rows carry token ids.
|
||||
|
||||
``refresh=1`` skips the cache, at most once per 10 s per caller and server;
|
||||
past that the cached answer is served with ``refreshed: false``.
|
||||
|
||||
User limits are advisory: REST can't see who is connected, so the edge's
|
||||
``channel_full`` stays the enforcement.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import get_role, require_firebase_token, require_service_key_or_admin
|
||||
from app.routers.discord_web import _guild_name, _node_for_caller, _org_guilds
|
||||
|
||||
router = APIRouter(prefix="/discord", tags=["discord"])
|
||||
|
||||
REFRESH_EVERY_SECONDS = 10.0
|
||||
_SNOWFLAKE = r"^[0-9]{1,25}$"
|
||||
_last_refresh: dict[tuple[str, str], float] = {}
|
||||
|
||||
|
||||
def _refresh_allowed(caller: str, guild_id: str) -> bool:
|
||||
now = time.monotonic()
|
||||
key = (caller, guild_id)
|
||||
if now - _last_refresh.get(key, -1e9) < REFRESH_EVERY_SECONDS:
|
||||
return False
|
||||
_last_refresh[key] = now
|
||||
if len(_last_refresh) > 5000: # bounded: drop entries past the window
|
||||
for k in [k for k, t in _last_refresh.items() if now - t >= REFRESH_EVERY_SECONDS]:
|
||||
_last_refresh.pop(k, None)
|
||||
return True
|
||||
|
||||
|
||||
def _availability(tok: dict, *, guild_id: str, node_id: Optional[str]) -> Optional[str]:
|
||||
"""None when the bot could take a new stream here, else why not."""
|
||||
if any(leases.guild_of(s) == guild_id for s in leases._lease_streams(tok)):
|
||||
return "busy_here" # one voice connection per bot per server
|
||||
lease_node = (tok.get("lease") or {}).get("node_id")
|
||||
if node_id and lease_node and lease_node != node_id:
|
||||
return "other_node" # logged in on another node
|
||||
return None
|
||||
|
||||
|
||||
def _channel_state(rows: list[dict], enabled: bool) -> str:
|
||||
if not enabled:
|
||||
return "unchecked"
|
||||
if any(r["status"] == "bad_channel" for r in rows):
|
||||
return "bad_channel"
|
||||
if not rows:
|
||||
return "no_bot"
|
||||
free = [r for r in rows if r["available"]]
|
||||
if any(r["ok"] for r in free):
|
||||
return "ready"
|
||||
if any(r["status"] == discord_rest.UNKNOWN for r in free):
|
||||
return "unknown" # fail open: a start is still tried
|
||||
if any(r["status"] == leases.PERM_DENIED for r in free):
|
||||
return "needs_fix" # a start is refused with missing_permissions
|
||||
if any(r["ok"] for r in rows):
|
||||
return "busy"
|
||||
return "no_bot"
|
||||
|
||||
|
||||
async def voice_check(*, guild_id: str, org_id: Optional[str], node_id: Optional[str],
|
||||
channels: list[dict], channel_id: Optional[str], refresh: bool,
|
||||
with_token_ids: bool) -> dict:
|
||||
"""The JSON contract (see the PR for server-26#225 S3). ``channels`` is
|
||||
the server's directory (``[{channel_id, name}]``); when it is empty the
|
||||
voice channels a bot's snapshot lists are used instead."""
|
||||
enabled = settings.discord_perm_check_enabled
|
||||
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||
eligible = {tid: t for tid, t in tokens.items()
|
||||
if t.get("valid") is not False and t.get("org_id") in (None, org_id)}
|
||||
members = [tid for tid, t in eligible.items()
|
||||
if t.get("guild_ids") is None or guild_id in t["guild_ids"]]
|
||||
members.sort(key=lambda t: leases._candidate_order(t, eligible[t], node_id or "", org_id, None))
|
||||
invite = [{"name": t.get("name") or tid, "invite_url": leases.invite_url(t.get("application_id"), guild_id)}
|
||||
for tid, t in sorted(eligible.items(), key=lambda kv: str(kv[1].get("name") or kv[0]))
|
||||
if tid not in members and leases.invitable(t) and t.get("application_id")][:5]
|
||||
|
||||
snaps = (await streams.gather_snapshots(tokens, [m for m in members if tokens[m].get("token")], guild_id,
|
||||
refresh=refresh)
|
||||
if enabled else {})
|
||||
|
||||
picked = [{"channel_id": str(c.get("channel_id")), "name": c.get("name")} for c in channels]
|
||||
if not picked:
|
||||
seen = next((s for s in snaps.values() if s is not None and s.status == discord_rest.OK), None)
|
||||
picked = [{"channel_id": str(c.get("id")), "name": c.get("name")}
|
||||
for c in sorted((seen.channels if seen else []) or [],
|
||||
key=lambda c: (c.get("position") or 0, str(c.get("name") or "")))
|
||||
if isinstance(c, dict) and c.get("type") == 2]
|
||||
if channel_id:
|
||||
picked = [c for c in picked if c["channel_id"] == channel_id] or [{"channel_id": channel_id, "name": None}]
|
||||
|
||||
# Bots whose rows say "not a member" after the check move to the invite list.
|
||||
newly_out = {t for t, s in snaps.items() if s is not None and s.status == discord_rest.NOT_MEMBER}
|
||||
out_channels = []
|
||||
for c in picked:
|
||||
rows = []
|
||||
for tid in members:
|
||||
if tid in newly_out:
|
||||
continue
|
||||
tok = tokens[tid]
|
||||
name = tok.get("name") or tid
|
||||
if not enabled:
|
||||
res = {"status": "unchecked", "missing": [], "reason": None, "fix": None}
|
||||
elif snaps.get(tid) is None:
|
||||
res = streams._unanswered()
|
||||
else:
|
||||
res = streams.evaluate_access(snaps[tid], c["channel_id"], bot=name)
|
||||
why = _availability(tok, guild_id=guild_id, node_id=node_id)
|
||||
row = {
|
||||
"name": name,
|
||||
"ok": res["status"] == leases.PERM_OK,
|
||||
"status": res["status"],
|
||||
"missing": res.get("missing") or [],
|
||||
"reason": res.get("reason") or (res.get("error") if res["status"] == discord_rest.UNKNOWN else None),
|
||||
"fix": res.get("fix"),
|
||||
"available": why is None,
|
||||
"unavailable_reason": why,
|
||||
"user_limit": res.get("user_limit") or 0,
|
||||
"limit_bypass": bool(res.get("limit_bypass")),
|
||||
}
|
||||
if with_token_ids:
|
||||
row["token_id"] = tid
|
||||
rows.append(row)
|
||||
if not c["name"] and res.get("channel_name"):
|
||||
c["name"] = res["channel_name"]
|
||||
limit = max((r["user_limit"] for r in rows), default=0)
|
||||
out_channels.append({
|
||||
"channel_id": c["channel_id"],
|
||||
"name": c["name"],
|
||||
"state": _channel_state(rows, enabled),
|
||||
"any_ok": any(r["ok"] for r in rows),
|
||||
"user_limit": limit,
|
||||
"limit_bypass": any(r["ok"] and r["limit_bypass"] for r in rows),
|
||||
"bots": [{k: v for k, v in r.items() if k not in ("user_limit", "limit_bypass")} for r in rows],
|
||||
})
|
||||
for tid in sorted(newly_out):
|
||||
t = tokens[tid]
|
||||
if leases.invitable(t) and t.get("application_id") and len(invite) < 5:
|
||||
invite.append({"name": t.get("name") or tid,
|
||||
"invite_url": leases.invite_url(t.get("application_id"), guild_id)})
|
||||
return {
|
||||
"guild_id": guild_id,
|
||||
"node_id": node_id,
|
||||
"enabled": enabled,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"refreshed": refresh,
|
||||
"channels": out_channels,
|
||||
"invite": invite,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/nodes/{node_id}/guilds/{guild_id}/voice-check")
|
||||
async def node_voice_check(node_id: str, guild_id: str,
|
||||
refresh: int = Query(0, ge=0, le=1),
|
||||
channel_id: Optional[str] = Query(None, pattern=_SNOWFLAKE),
|
||||
decoded: dict = Depends(require_firebase_token)):
|
||||
node, _ = await _node_for_caller(node_id, decoded)
|
||||
org_id = node.get("org_id")
|
||||
guild = next((g for g in (await _org_guilds(org_id) if org_id else []) if g["guild_id"] == str(guild_id)), None)
|
||||
if not guild:
|
||||
raise HTTPException(404, "That Discord server is not linked to this organization.")
|
||||
do_refresh = bool(refresh) and _refresh_allowed(f"uid:{decoded.get('uid') or '?'}", guild["guild_id"])
|
||||
out = await voice_check(guild_id=guild["guild_id"], org_id=org_id, node_id=node_id,
|
||||
channels=guild.get("voice_channels") or [], channel_id=channel_id,
|
||||
refresh=do_refresh, with_token_ids=get_role(decoded) == "admin")
|
||||
out["guild_name"] = _guild_name(guild)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/guilds/{guild_id}/voice-check")
|
||||
async def guild_voice_check(guild_id: str,
|
||||
channel_id: Optional[str] = Query(None, pattern=_SNOWFLAKE),
|
||||
refresh: int = Query(0, ge=0, le=1),
|
||||
decoded: dict = Depends(require_service_key_or_admin)):
|
||||
if not guild_id.isdigit():
|
||||
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
|
||||
org_id = await streams.resolve_guild_org(guild_id)
|
||||
link = await fstore.doc_get(streams.GUILDS, guild_id) or {}
|
||||
caller = "service" if decoded.get("service") else f"uid:{decoded.get('uid') or '?'}"
|
||||
do_refresh = bool(refresh) and _refresh_allowed(caller, guild_id)
|
||||
out = await voice_check(guild_id=guild_id, org_id=org_id, node_id=None,
|
||||
channels=link.get("voice_channels") or [], channel_id=channel_id,
|
||||
refresh=do_refresh, with_token_ids=True)
|
||||
out["guild_name"] = _guild_name(link)
|
||||
out["org_id"] = org_id
|
||||
return out
|
||||
@@ -5,7 +5,14 @@ from typing import Optional
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.audit import write_audit
|
||||
from app.internal.auth import (
|
||||
describe_actor,
|
||||
get_org_role,
|
||||
get_role,
|
||||
require_admin_token,
|
||||
require_firebase_token,
|
||||
)
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||
@@ -94,35 +101,99 @@ async def flush_tokens(_: dict = Depends(require_admin_token)):
|
||||
return {"released": released}
|
||||
|
||||
|
||||
def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool:
|
||||
"""Platform admin: any system. Org owner: only their own org's systems —
|
||||
a shared system (no org_id) serves every org, so a preference there is
|
||||
platform-admin only (owner decision Q2, 2026-10-05)."""
|
||||
if get_role(decoded) == "admin":
|
||||
return True
|
||||
return bool(system and system.get("org_id") and system.get("org_id") == decoded.get("org_id"))
|
||||
|
||||
|
||||
def _check_prefer_access(decoded: dict, token: Optional[dict], system: Optional[dict],
|
||||
system_id: Optional[str]) -> None:
|
||||
"""Raise unless the caller may point ``system`` at ``token``. Another
|
||||
org's bot or system is a 404, never a 403 (no existence leak)."""
|
||||
if get_role(decoded) == "admin":
|
||||
if not token:
|
||||
raise HTTPException(404, "Token not found.")
|
||||
if system_id is not None and not system:
|
||||
raise HTTPException(404, "System not found.")
|
||||
return
|
||||
org_id = decoded.get("org_id")
|
||||
if not org_id or get_org_role(decoded) != "owner":
|
||||
raise HTTPException(403, "Only the organization owner or a platform admin can choose a system's radio bot.")
|
||||
if not token or (token.get("org_id") is not None and token.get("org_id") != org_id):
|
||||
raise HTTPException(404, "Token not found.")
|
||||
if token.get("org_id") is None:
|
||||
raise HTTPException(403, "Only a platform admin can make a shared DRB radio bot preferred. "
|
||||
"Organization owners can choose their own organization's radio bots.")
|
||||
if system_id is None:
|
||||
return
|
||||
if not system or (system.get("org_id") and system.get("org_id") != org_id):
|
||||
raise HTTPException(404, "System not found.")
|
||||
if not system.get("org_id"):
|
||||
raise HTTPException(403, "That system is shared by every organization; only a platform admin can "
|
||||
"choose its radio bot.")
|
||||
|
||||
|
||||
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
||||
async def set_preferred_system(
|
||||
token_id: str,
|
||||
system_id: str,
|
||||
_: dict = Depends(require_admin_token),
|
||||
decoded: dict = Depends(require_firebase_token),
|
||||
):
|
||||
"""
|
||||
Mark this token as the preferred bot for a system.
|
||||
When a discord_join is issued for any node in that system, this token
|
||||
is tried first before falling back to the general pool.
|
||||
Mark this token as the preferred bot for a system: ``start_stream`` tries
|
||||
it first for that system's radio channels when nobody named a bot
|
||||
(server-26#225 S3), and uses another bot when it can't take the stream.
|
||||
Pass system_id="_none" to clear the preference.
|
||||
|
||||
Who (owner decision Q2, 2026-10-05): a platform admin for any bot and any
|
||||
system; an org owner for their OWN org's systems and their OWN
|
||||
bring-your-own bots. Shared systems (no org_id) and pool bots stay
|
||||
platform-admin only.
|
||||
"""
|
||||
if "/" in token_id or "/" in system_id:
|
||||
raise HTTPException(400, "Bad id.")
|
||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, "Token not found.")
|
||||
uid, email = describe_actor(decoded)
|
||||
if system_id == "_none":
|
||||
# Clear any existing preference on the system that pointed to this token.
|
||||
system_doc = await fstore.doc_get("systems", existing.get("preferred_for_system_id", ""))
|
||||
if system_doc:
|
||||
await fstore.doc_set("systems", existing["preferred_for_system_id"], {"preferred_token_id": None})
|
||||
_check_prefer_access(decoded, existing, None, None)
|
||||
old_id = existing.get("preferred_for_system_id") or ""
|
||||
system_doc = await fstore.doc_get("systems", old_id) if old_id else None
|
||||
if system_doc and system_doc.get("preferred_token_id") == token_id:
|
||||
if not _may_edit_system(decoded, system_doc):
|
||||
raise HTTPException(403, "That system is shared by every organization; only a platform admin "
|
||||
"can change its radio bot.")
|
||||
await fstore.doc_set("systems", old_id, {"preferred_token_id": None})
|
||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
|
||||
await write_audit(uid, email, "discord.token.prefer", details={
|
||||
"token_id": token_id, "system_id": None, "previous_system_id": old_id or None})
|
||||
return {"ok": True, "preferred_for_system_id": None}
|
||||
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
if not system_doc:
|
||||
raise HTTPException(404, "System not found.")
|
||||
# Set preference on both sides for easy lookup in either direction.
|
||||
_check_prefer_access(decoded, existing, system_doc, system_id)
|
||||
|
||||
# Keep both pointers single-valued: this bot stops being preferred for the
|
||||
# system it was preferred for (when the caller may edit that system), and
|
||||
# the bot this system preferred before loses its back-pointer.
|
||||
old_sys_id = existing.get("preferred_for_system_id")
|
||||
if old_sys_id and old_sys_id != system_id:
|
||||
old_sys = await fstore.doc_get("systems", old_sys_id)
|
||||
if old_sys and old_sys.get("preferred_token_id") == token_id and _may_edit_system(decoded, old_sys):
|
||||
await fstore.doc_set("systems", old_sys_id, {"preferred_token_id": None})
|
||||
old_tok_id = system_doc.get("preferred_token_id")
|
||||
if old_tok_id and old_tok_id != token_id:
|
||||
old_tok = await fstore.doc_get("bot_tokens", old_tok_id)
|
||||
if old_tok and old_tok.get("preferred_for_system_id") == system_id:
|
||||
await fstore.doc_set("bot_tokens", old_tok_id, {"preferred_for_system_id": None})
|
||||
|
||||
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
|
||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
|
||||
await write_audit(uid, email, "discord.token.prefer", details={
|
||||
"token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id,
|
||||
"org_id": system_doc.get("org_id")})
|
||||
return {"ok": True, "preferred_for_system_id": system_id}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user