From b5b4f188a39fc96c4d32e67151e067b1add6dcfc Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 02:51:00 +0200 Subject: feat(hub): let a hub admin disable public groups instance-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new General tab in Administration carries one switch, allow_public_groups, stored in a hub_settings key/value table (runtime-editable, unlike hub.toml). Default is on; an absent row means on, so an upgrade changes nothing. Enforcement is server-side on every hub-mediated path, not just the SPA: - create_group refuses visibility=public (403), staff included - list_public_groups the directory returns nothing (local + federated) - join_group open-joining a public group is refused - group_online_nodes a non-member of a public group is handed no node - signaling.webrtc_offer drops the "node hosts an open group" fallback - federation.export_directory advertises nothing to peer hubs The switch is read live, so flipping it back restores every path. Existing members of a group that predates the switch keep their membership row and their access — this is plan A, not a purge. GET /v1/hub/info exposes the flag (unauthenticated) so the create-group form and the sidebar's "Public groups" link render correctly. Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets status=revoked and broadcasts a signed revocation every node enforces (denylist + dropped live sessions). It is confirm-guarded and names the group. And a message fix the revoke work surfaced: group_online_nodes, join_group and webrtc_offer answered "Group is suspended" for any non-active status. They now report the real state, so a member of a revoked group is told "Group is revoked" rather than something reversible-sounding. Tests: test_public_groups_toggle.py (10) covers the switch end to end and the five enforcement paths; test_revocation.py gains the status-message assertion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi --- packages/meshbay-hub/src/meshbay_hub/api/admin.py | 50 ++++++ .../meshbay-hub/src/meshbay_hub/api/federation.py | 14 +- packages/meshbay-hub/src/meshbay_hub/api/groups.py | 34 +++- packages/meshbay-hub/src/meshbay_hub/api/hub.py | 15 +- .../meshbay-hub/src/meshbay_hub/api/signaling.py | 33 ++-- .../versions/b1c2d3e4f5a6_add_hub_settings.py | 34 ++++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 20 +++ .../meshbay-hub/src/meshbay_hub/hub_settings.py | 44 +++++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 187 +++++++++++++++------ .../src/meshbay_hub/static/locales/de.js | 7 + .../src/meshbay_hub/static/locales/en.js | 7 + .../src/meshbay_hub/static/locales/es.js | 7 + .../src/meshbay_hub/static/locales/fr.js | 7 + .../src/meshbay_hub/static/locales/it.js | 7 + .../src/meshbay_hub/static/locales/ja.js | 7 + .../src/meshbay_hub/static/locales/nl.js | 7 + .../src/meshbay_hub/static/locales/pl.js | 7 + .../src/meshbay_hub/static/locales/pt-BR.js | 7 + .../src/meshbay_hub/static/locales/zh-CN.js | 7 + 19 files changed, 426 insertions(+), 75 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py create mode 100644 packages/meshbay-hub/src/meshbay_hub/hub_settings.py (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 7ee05ca..4141c79 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -18,6 +18,7 @@ from meshbay_hub.api.deps import require_admin, require_moderator from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User +from meshbay_hub import hub_settings log = logging.getLogger(__name__) @@ -35,6 +36,55 @@ class GroupPatchRequest(BaseModel): status: str | None = None +class SettingsPatchRequest(BaseModel): + allow_public_groups: bool | None = None + + +# ── Instance settings ──────────────────────────────────────────────────────── + +def _settings_payload(allow_public_groups: bool) -> dict: + return {"allow_public_groups": allow_public_groups} + + +@router.get("/settings") +async def admin_get_settings( + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), +): + """Instance-wide policy an admin controls from the panel. Moderators may read.""" + return _settings_payload(await hub_settings.public_groups_allowed(db)) + + +@router.patch("/settings") +async def admin_patch_settings( + body: SettingsPatchRequest, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + Change instance policy. Admin only — moderators get the read above. + + The enforcement lives where the thing being restricted happens (public-group + creation is refused in `groups.create_group`), so flipping this here is the + whole change: a client that keeps drawing the option still cannot use it. + """ + if body.allow_public_groups is not None: + await hub_settings.set_raw( + db, hub_settings.ALLOW_PUBLIC_GROUPS, + "true" if body.allow_public_groups else "false") + log.info("Instance setting allow_public_groups=%s by %s", + body.allow_public_groups, current_user.username) + db.add(IPLog( + user_id=current_user.id, + event="admin_settings_update", + ip_address="admin", + detail=f"allow_public_groups={body.allow_public_groups}", + )) + await db.commit() + + return _settings_payload(await hub_settings.public_groups_allowed(db)) + + # ── Stats ──────────────────────────────────────────────────────────────────── @router.get("/stats") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py index b6b4acf..7f1262d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py @@ -31,7 +31,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_common import MHP_VERSION -from meshbay_hub import __version__ +from meshbay_hub import __version__, hub_settings from meshbay_hub.api.deps import require_admin from meshbay_hub.auth import _hub_id, _hub_sk_pem, hub_public_key_pem from meshbay_hub.db.engine import get_db @@ -104,9 +104,15 @@ async def export_directory( except Exception as e: raise HTTPException(status_code=401, detail=str(e)) - result = await db.execute( - select(Group).where(Group.visibility == "public", Group.status == "active")) - groups = result.scalars().all() + # A hub with public groups switched off advertises nothing to its peers — + # the local directory is empty (groups.list_public_groups), and the exported + # one has to match or peers keep showing groups this hub no longer serves. + if not await hub_settings.public_groups_allowed(db): + groups = [] + else: + result = await db.execute( + select(Group).where(Group.visibility == "public", Group.status == "active")) + groups = result.scalars().all() return { "hub_id": _hub_id, diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index e78d950..91aa9c4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub import hub_settings from meshbay_hub.api.deps import get_current_user, require_user_scope from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db @@ -107,7 +108,17 @@ async def group_online_nodes( if not group: raise HTTPException(status_code=404, detail="Group not found") if group.status != "active": - raise HTTPException(status_code=403, detail="Group is suspended") + # Name the real state: "suspended" is reversible, "revoked" is a signed + # instruction every node enforces. The client shows this string as-is. + raise HTTPException(status_code=403, detail=f"Group is {group.status}") + + # A public group is normally readable by anyone — that is the point of it. + # But when the hub has public groups switched off, an existing one keeps + # working only for the people already in it: no non-member gets handed a + # node to connect to. Members always have a row here, so they are unaffected. + if group.visibility == "public" and not await hub_settings.public_groups_allowed(db): + if not await db.get(GroupMember, (group_id, current_user.id)): + raise HTTPException(status_code=403, detail="Not a member of this group") node_ids = get_online_nodes_for_group(group_id) nodes = [] @@ -127,6 +138,12 @@ async def list_public_groups( include_federated: bool = True, ): """List/search public groups — local and optionally federated. No auth required.""" + # The hub admin can switch public groups off for the whole instance. When + # they have, there is no directory at all — not the local groups that predate + # the switch, and not the federated ones a peer still advertises. The switch + # is read live, so flipping it back brings the directory straight back. + if not await hub_settings.public_groups_allowed(db): + return {"groups": [], "total": 0} # Unhosted groups are absent from the directory: until a node announces it, # a group has no files, no key and nothing to connect to, so listing it only # produces a dead end. Its owner still sees it in /mine while they set it up. @@ -272,9 +289,14 @@ async def join_group( if not group: raise HTTPException(status_code=404, detail="Group not found") if group.status != "active": - raise HTTPException(status_code=403, detail="Group is not active") + raise HTTPException(status_code=403, detail=f"Group is {group.status}") if group.join_policy != "open": raise HTTPException(status_code=403, detail="Group does not allow open joining") + if group.visibility == "public" and not await hub_settings.public_groups_allowed(db): + # The group predates the switch; open joining is off with it. Existing + # members keep their row and their access. + raise HTTPException( + status_code=403, detail="This hub does not allow joining public groups.") existing = await db.get(GroupMember, (group_id, current_user.id)) if existing: @@ -365,6 +387,14 @@ async def create_group( detail="A private group is invite-only. Make it public if you want " "anyone to be able to join.") if body.visibility == "public": + if not await hub_settings.public_groups_allowed(db): + # Instance policy, set by a hub admin in the panel. Absolute — staff + # included — because the way back is to re-enable it, not to slip + # past it. A client that still shows the "open" choice lands here. + raise HTTPException( + status_code=403, + detail="This hub does not allow public groups. Create the group " + "as private — you can still invite people to it.") await _check_public_group_quota(db, current_user) desc = (body.description or "")[:512] if body.description else None diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 4aef93d..8692995 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -1,22 +1,29 @@ """Hub info endpoints — /v1/hub/*""" -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + from meshbay_common import MNP_VERSION, MHP_VERSION -from meshbay_hub import __version__ +from meshbay_hub import __version__, hub_settings from meshbay_hub.auth import hub_public_key_pem -from meshbay_hub.db.engine import get_engine +from meshbay_hub.db.engine import get_db, get_engine router = APIRouter(prefix="/v1/hub", tags=["hub"]) @router.get("/info") -async def hub_info(): +async def hub_info(db: AsyncSession = Depends(get_db)): engine = get_engine() return { "hub_version": __version__, "mnp_version": MNP_VERSION, "mhp_version": MHP_VERSION, "db_dialect": engine.dialect.name, + # Instance policy the SPA needs before drawing the create-group form. + # Unauthenticated on purpose: it is not a secret, and the form is + # reachable before the group list loads. The hub enforces it regardless + # of what any client does with this flag. + "allow_public_groups": await hub_settings.public_groups_allowed(db), } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index 84c1167..a8feae8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -22,6 +22,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub import hub_settings from meshbay_hub.api.deps import get_current_user from meshbay_hub.api.middleware import limiter from meshbay_hub.db.engine import get_db @@ -93,6 +94,11 @@ async def webrtc_offer( # The caller must share at least one active group with the target node, # OR the node must host at least one open-join group (public groups admit # anyone — the node's MNP handshake handles authorization). + # + # That second path is exactly what "public groups" means, so it is gated by + # the instance switch: with public groups off, a non-member is not brokered a + # connection to a node just because it happens to host an open group. Members + # of that group are unaffected — they match `shared` below. node_group_ids = set(_node_groups.get(node_id, [])) if node_group_ids: result = await db.execute( @@ -102,19 +108,24 @@ async def webrtc_offer( )) shared = [gid for (gid,) in result.all()] if not shared: - has_open = await db.execute( - select(Group.id).where( - Group.id.in_(node_group_ids), - Group.join_policy == "open", - Group.status == "active", - )) - if not has_open.first(): + has_open = None + if await hub_settings.public_groups_allowed(db): + has_open = (await db.execute( + select(Group.id).where( + Group.id.in_(node_group_ids), + Group.join_policy == "open", + Group.status == "active", + ))).first() + if not has_open: raise HTTPException(status_code=403, detail="Not a member of any group on this node") else: - active = await db.execute( - select(Group.id).where(Group.id.in_(shared), Group.status == "active")) - if not active.first(): - raise HTTPException(status_code=403, detail="Group is not active") + statuses = set((await db.execute( + select(Group.status).where(Group.id.in_(shared)))).scalars().all()) + if "active" not in statuses: + # Report the strongest state present — "revoked" is the signed, + # node-enforced one; "suspended" is the reversible hub flag. + state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended") + raise HTTPException(status_code=403, detail=f"Group is {state}") if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER: raise HTTPException(status_code=429, detail="Too many pending connections") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py new file mode 100644 index 0000000..13f2b5c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py @@ -0,0 +1,34 @@ +"""add_hub_settings + +Revision ID: b1c2d3e4f5a6 +Revises: a7b8c9d0e1f2 +Create Date: 2026-08-28 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b1c2d3e4f5a6' +down_revision: Union[str, Sequence[str], None] = 'a7b8c9d0e1f2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'hub_settings', + sa.Column('key', sa.String(64), nullable=False), + sa.Column('value', sa.Text(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('key'), + ) + # No seed rows: an absent key is the default, and writing "true" here would + # make a later change of default invisible to an already-migrated hub. + + +def downgrade() -> None: + op.drop_table('hub_settings') diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 3966bdc..0c337c1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -299,6 +299,26 @@ class UserPreference(Base): value: Mapped[str] = mapped_column(Text, nullable=False) +class HubSetting(Base): + """ + Instance-wide settings an admin changes at runtime from the panel. + + Deliberately a key/value table rather than fields in `hub.toml`: the config + file is read once at boot and editing it means an SSH session and a restart, + which is not what "toggle this from the admin page" means. Anything here is a + policy the running hub can change on itself. + + Absent key ⇒ the built-in default (see `meshbay_hub.hub_settings`). An older + hub with no row behaves exactly as it did before the setting existed. + """ + __tablename__ = "hub_settings" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, onupdate=_now) + + class IPLog(Base): """ Connection log for legal compliance. diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py new file mode 100644 index 0000000..280d1e2 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py @@ -0,0 +1,44 @@ +""" +Instance-wide settings stored in the `hub_settings` table. + +One reader per concern, so callers never touch raw strings or key names. A +missing row means the built-in default — an upgrade never changes behaviour on +its own, and a downgrade that drops the table just returns to defaults. +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub.db.models import HubSetting + +# Whether a member may create a group that is listed in the public directory and +# open for anyone to join. Off makes the hub private-groups-only. +ALLOW_PUBLIC_GROUPS = "allow_public_groups" + +_DEFAULTS: dict[str, str] = { + ALLOW_PUBLIC_GROUPS: "true", +} + + +async def get_raw(db: AsyncSession, key: str) -> str | None: + row = await db.get(HubSetting, key) + return row.value if row else None + + +async def set_raw(db: AsyncSession, key: str, value: str) -> None: + """Upsert. The caller owns the commit.""" + row = await db.get(HubSetting, key) + if row: + row.value = value + else: + db.add(HubSetting(key=key, value=value)) + + +async def get_bool(db: AsyncSession, key: str) -> bool: + raw = await get_raw(db, key) + if raw is None: + raw = _DEFAULTS.get(key, "false") + return raw == "true" + + +async def public_groups_allowed(db: AsyncSession) -> bool: + return await get_bool(db, ALLOW_PUBLIC_GROUPS) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 446360c..a380033 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -299,7 +299,8 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, // ── Sidebar ────────────────────────────────────────────────────────────────── -function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey }) { +function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey, + allowPublicGroups = true }) { const isStaff = role === 'moderator' || role === 'admin'; return html`