aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py65
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py51
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a8_admin_pins.py29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py21
-rw-r--r--packages/meshbay-hub/tests/test_admin_pins.py121
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)