diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 12:37:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 12:37:31 +0200 |
| commit | 8a4651e9d223de856ff085b329801998f95db138 (patch) | |
| tree | 6153ffaf46030613fa9024e85b9890c7fa979154 /packages | |
| parent | 1684fcb64531eaf2e9bb8567ba4bdcbada6469d8 (diff) | |
| download | meshbay-8a4651e9d223de856ff085b329801998f95db138.tar.gz | |
fix(hub): the admin allow-list grants an account, not a username
Each name in admin_usernames is pinned to the first active account seen
holding it (admin_pins), so a name freed by a deletion grants nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
5 files changed, 269 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index 2fd8b99..56af023 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -7,20 +7,70 @@ JWT scope enforcement: Node-scoped tokens CANNOT create/delete groups or manage membership. """ +import logging + from fastapi import Depends, Header, HTTPException, status from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import decode_access_token from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import User +from meshbay_hub.db.models import AdminPin, User + +log = logging.getLogger(__name__) +# `hub.toml`'s `admin_usernames`, and the account each of those names was pinned +# to (`AdminPin`). The names only say which accounts to pin; what grants the +# role is the pinned id, so a listed name released by an account deletion and +# registered again by somebody else grants nothing. _admin_usernames: set[str] = set() +_admin_pins: dict[str, str] = {} def set_admin_usernames(usernames: list[str]) -> None: - global _admin_usernames + global _admin_usernames, _admin_pins _admin_usernames = set(usernames) + _admin_pins = {} + + +def set_admin_pins(pins: dict[str, str]) -> None: + global _admin_pins + _admin_pins = {name: uid for name, uid in pins.items() if name in _admin_usernames} + + +def _is_pinned_admin(user: User) -> bool: + return user.id in _admin_pins.values() + + +async def _pin_if_listed(db: AsyncSession, user: User) -> None: + """Pin an allow-listed name to the first active account seen holding it. + + Startup pins every listed name that already has an account; this covers a + name registered while the hub runs, so the operator's own first sign-in is + an admin one without a restart, as it was before pins existed. + """ + name = user.username + if name not in _admin_usernames or name in _admin_pins: + return + pin = await db.get(AdminPin, name) + if pin is None: + db.add(AdminPin(username=name, user_id=user.id)) + user.role = "admin" + try: + await db.commit() + except IntegrityError: + # Another worker pinned it first; its answer stands. + await db.rollback() + await db.refresh(user) + pin = await db.get(AdminPin, name) + if pin is None: + return + else: + log.info("Admin allow-list: %s pinned to account %s", name, user.id[:8]) + _admin_pins[name] = user.id + return + _admin_pins[name] = pin.user_id async def _decode_token(authorization: str = Header(...)) -> dict: @@ -56,6 +106,7 @@ async def get_current_user( if user.status != "active": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Account {user.status}") + await _pin_if_listed(db, user) return user @@ -102,14 +153,14 @@ async def require_node_scope( def user_is_admin(user: User) -> bool: - """Admin by DB role or by the config allow-list. Use inside a handler that - already depends on `require_moderator` but has to draw the admin line for - one field (see `admin_patch_user`).""" - return user.role == "admin" or user.username in _admin_usernames + """Admin by DB role or as the account a config allow-listed name is pinned + to. Use inside a handler that already depends on `require_moderator` but has + to draw the admin line for one field (see `admin_patch_user`).""" + return user.role == "admin" or _is_pinned_admin(user) def user_is_moderator(user: User) -> bool: - return user.role in ("moderator", "admin") or user.username in _admin_usernames + return user.role in ("moderator", "admin") or _is_pinned_admin(user) async def require_moderator( diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 16a9d4a..ca05fd8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -42,22 +42,52 @@ from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import close_db, init_db -async def _sync_admin_roles(admin_usernames: list[str]) -> None: - """Ensure config-listed admin usernames have role='admin' in the DB.""" +async def _pin_config_admins(admin_usernames: list[str]) -> None: + """Pin each allow-listed admin name to the account holding it, once. + + A name already pinned keeps its account whoever holds the name now, which is + what stops a released name from being registered into the admin role. Pins + of names no longer listed are dropped: removing a name, restarting, then + listing it again is how an operator hands it to a new account. + """ + import logging + from sqlalchemy import select + from meshbay_hub.api.deps import set_admin_pins from meshbay_hub.db.engine import get_session_factory - from meshbay_hub.db.models import User + from meshbay_hub.db.models import AdminPin, User + log = logging.getLogger(__name__) + listed = set(admin_usernames) factory = get_session_factory() async with factory() as session: - result = await session.execute( - select(User).where(User.username.in_(admin_usernames)) - ) - for user in result.scalars().all(): - if user.role != "admin": - user.role = "admin" + pins: dict[str, str] = {} + for pin in (await session.execute(select(AdminPin))).scalars().all(): + if pin.username in listed: + pins[pin.username] = pin.user_id + else: + log.info("Admin allow-list: %s is no longer listed, its pin is dropped", + pin.username) + await session.delete(pin) + + unpinned = listed - pins.keys() + if unpinned: + holders = (await session.execute(select(User).where( + User.username.in_(unpinned), User.status == "active"))).scalars().all() + for user in holders: + session.add(AdminPin(username=user.username, user_id=user.id)) + pins[user.username] = user.id + log.info("Admin allow-list: %s pinned to account %s", + user.username, user.id[:8]) + + if pins: + for user in (await session.execute(select(User).where( + User.id.in_(pins.values()), User.status == "active"))).scalars().all(): + if user.role != "admin": + user.role = "admin" await session.commit() + set_admin_pins(pins) async def _backfill_email_hashes() -> None: @@ -123,8 +153,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: # back to. _hub_settings.set_mail_defaults(cfg.mail) - if cfg.identity.admin_usernames: - await _sync_admin_roles(cfg.identity.admin_usernames) + await _pin_config_admins(cfg.identity.admin_usernames) from meshbay_hub.db.engine import get_session_factory from meshbay_hub.tasks.cleanup import cleanup_loop diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a8_admin_pins.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a8_admin_pins.py new file mode 100644 index 0000000..57de9b2 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a8_admin_pins.py @@ -0,0 +1,29 @@ +"""the admin allow-list grants an account, not whoever holds the name + +Revision ID: b2c3d4e5f6a8 +Revises: a1b2c3d4e5f7 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b2c3d4e5f6a8" +down_revision: str | Sequence[str] | None = "a1b2c3d4e5f7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "admin_pins", + sa.Column("username", sa.String(64), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False), + sa.Column("pinned_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("admin_pins") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index a0437d6..293b940 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -437,6 +437,27 @@ class LoginThrottle(Base): last_failure_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) +class AdminPin(Base): + """ + Which account an allow-listed admin name (`hub.toml` `admin_usernames`) + belongs to, recorded the first time an active account holds it. + + The allow-list names people by username, and a username is not an identity: + deleting an account releases its name, and whoever registered it next + inherited the admin role. The allow-list now grants the pinned + account, so a name that changes hands grants nothing. No foreign key, on + purpose: the pin has to outlive the account it points at, or the name would + be free to pin again. A name removed from the allow-list loses its pin at + the next start, which is how an operator hands a listed name to a new + account. + """ + __tablename__ = "admin_pins" + + username: Mapped[str] = mapped_column(String(64), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), nullable=False) + pinned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + class HubSetting(Base): """ Instance-wide settings an admin changes at runtime from the panel. diff --git a/packages/meshbay-hub/tests/test_admin_pins.py b/packages/meshbay-hub/tests/test_admin_pins.py new file mode 100644 index 0000000..7e7affc --- /dev/null +++ b/packages/meshbay-hub/tests/test_admin_pins.py @@ -0,0 +1,121 @@ +""" +The `hub.toml` admin allow-list grants an account, not a username. + +Deleting an account releases its name, so a list of names granted the admin role +to whoever registered a freed one next. Each listed name is now pinned to the +first active account seen holding it, and only that account is the admin. +""" + +import hashlib + +import pytest +from meshbay_hub.api.deps import set_admin_usernames +from meshbay_hub.app import _pin_config_admins +from meshbay_hub.db.models import AdminPin, User +from sqlalchemy import select + + +def _auth_key(password: str, username: str) -> str: + import base64 + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +PASSWORD = "a-long-enough-passphrase" + + +async def _register(client, username, email=None): + r = await client.post("/v1/users/register", json={ + "username": username, "email": email or f"{username}@example.com", + "auth_key": _auth_key(PASSWORD, username), + }) + assert r.status_code in (200, 201), r.text + login = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(PASSWORD, username)}) + assert login.status_code == 200, login.text + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +async def _delete_own(client, headers, username): + r = await client.request("DELETE", "/v1/users/me", headers=headers, + json={"auth_key": _auth_key(PASSWORD, username)}) + assert r.status_code == 200, r.text + + +async def _is_admin(client, headers) -> bool: + r = await client.get("/v1/admin/stats", headers=headers) + assert r.status_code in (200, 403), r.text + return r.status_code == 200 + + +@pytest.mark.asyncio +async def test_a_released_name_registered_again_is_not_an_admin(client): + set_admin_usernames(["the_operator"]) + first = await _register(client, "the_operator") + assert await _is_admin(client, first) + + await _delete_own(client, first, "the_operator") + second = await _register(client, "the_operator", email="someone@example.com") + assert not await _is_admin(client, second) + + +@pytest.mark.asyncio +async def test_nor_after_a_restart(client, db_session): + """Startup must not pin the name again to whoever holds it now.""" + set_admin_usernames(["the_operator"]) + first = await _register(client, "the_operator") + assert await _is_admin(client, first) + await _delete_own(client, first, "the_operator") + second = await _register(client, "the_operator", email="someone@example.com") + + await _pin_config_admins(["the_operator"]) + + assert not await _is_admin(client, second) + impostor = (await db_session.execute( + select(User).where(User.username == "the_operator"))).scalar_one() + assert impostor.role == "user" + + +@pytest.mark.asyncio +async def test_startup_pins_an_existing_account_before_its_name_is_freed(client, db_session): + """The upgrade path: the listed account exists before any pin does.""" + first = await _register(client, "the_operator") + set_admin_usernames(["the_operator"]) + await _pin_config_admins(["the_operator"]) + + pin = await db_session.get(AdminPin, "the_operator") + owner = (await db_session.execute( + select(User).where(User.username == "the_operator"))).scalar_one() + assert pin is not None and pin.user_id == owner.id + assert owner.role == "admin" + + await _delete_own(client, first, "the_operator") + second = await _register(client, "the_operator", email="someone@example.com") + assert not await _is_admin(client, second) + + +@pytest.mark.asyncio +async def test_a_name_registered_while_the_hub_runs_is_admin_at_once(client): + """No restart between listing a name and its first sign-in, as before pins.""" + set_admin_usernames(["late_operator"]) + await _pin_config_admins(["late_operator"]) + headers = await _register(client, "late_operator") + assert await _is_admin(client, headers) + + +@pytest.mark.asyncio +async def test_unlisting_then_relisting_hands_the_name_to_its_new_holder(client, db_session): + set_admin_usernames(["the_operator"]) + first = await _register(client, "the_operator") + assert await _is_admin(client, first) + await _delete_own(client, first, "the_operator") + second = await _register(client, "the_operator", email="successor@example.com") + + set_admin_usernames([]) + await _pin_config_admins([]) + assert await db_session.get(AdminPin, "the_operator") is None + + set_admin_usernames(["the_operator"]) + await _pin_config_admins(["the_operator"]) + assert await _is_admin(client, second) |