Admin panel on its own port, and a registration gate

Three strands that only make sense together, so they land together.

Registration gate (migration 0016). POST /auth/register was open to anyone
who could reach the port. Now the first account on an empty instance is
always allowed, becomes the administrator, and closes registration behind
itself; after that it takes an admin reopening it. The decision is made
under a row lock on app_settings so two simultaneous first signups cannot
both see an empty table and both claim administrator. Existing installs
keep their oldest account as admin and start closed.

Instance default quota (migration 0017). users.quota_bytes is snapshotted
at creation from app_settings.default_quota_bytes, so changing the default
affects later accounts only and never resizes a mailbox that exists.

The panel is a separate app, not a page in the mail client. admin.html is
its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the
vendor chunk), with its own administrator sign-in. One nginx now serves two
listeners: :80 is the mail client and 404s both /admin.html and
/api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT
and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not
process isolation — CurrentAdmin still guards every admin route — so
ADMIN_PORT must be restricted to the LAN, which the runbook now covers for
both DSM and QNAP.

Also QNAP: deploy/webmail.sh finds Container Station's docker under
/share/*/.qpkg/container-station/bin, which is off an SSH session's PATH,
before anything shells out to it. docs/nas-install.md gains a QuTS hero
runbook (§2.6), splits the DSM firewall section into container-to-container
(§8.1) and the egress case that makes account-add time out while
autodiscovery appears to work (§8.2), and scopes both to Synology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-16 20:34:08 +02:00
co-authored by Claude Opus 5
parent cc63efa968
commit 8fce86559e
27 changed files with 1409 additions and 125 deletions
@@ -0,0 +1,240 @@
"""Registration policy: first user bootstraps as administrator, then signup closes.
The bootstrap cases run against the rollback-per-test ``db_session`` fixture, so
they can empty the users table to simulate a fresh install without touching the
dev database.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.service import (
RegistrationError,
app_settings,
register_user,
registration_status,
)
from app.identity.sessions import create_session
from app.main import create_app
from app.models import AppSettings, User
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
def _localpart() -> str:
return f"reg{uuid.uuid4().hex[:8]}"
# --- service level: the bootstrap decision ----------------------------------
async def test_first_user_becomes_admin_and_closes_registration(db_session: AsyncSession) -> None:
"""On an empty instance the first signup is allowed, is admin, and shuts the door."""
await db_session.execute(delete(User))
settings = await app_settings(db_session)
settings.registration_open = False # even closed, an empty instance must bootstrap
await db_session.flush()
status = await registration_status(db_session)
assert (status.open, status.bootstrap) == (True, True)
first = await register_user(
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
)
assert first.is_admin is True
settings = await app_settings(db_session)
assert settings.registration_open is False
status = await registration_status(db_session)
assert (status.open, status.bootstrap) == (False, False)
with pytest.raises(RegistrationError) as exc:
await register_user(
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
)
assert exc.value.code == "registration_closed"
async def test_reopened_registration_admits_plain_users(db_session: AsyncSession) -> None:
"""A second account created while open is an ordinary user, not an admin."""
await db_session.execute(delete(User))
await register_user(db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None)
settings = await app_settings(db_session)
settings.registration_open = True
await db_session.flush()
second = await register_user(
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
)
assert second.is_admin is False
# Reopening is a deliberate admin act; it stays open until they close it.
assert (await app_settings(db_session)).registration_open is True
async def test_missing_settings_row_defaults_to_closed(db_session: AsyncSession) -> None:
"""A database predating the settings row must not fall open."""
await db_session.execute(delete(AppSettings))
assert (await app_settings(db_session)).registration_open is False
# --- API level ---------------------------------------------------------------
@pytest_asyncio.fixture
async def api() -> AsyncIterator[tuple[httpx.AsyncClient, AsyncSession, uuid.UUID]]:
"""A logged-in non-admin user plus a restored-on-exit app_settings row."""
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
before = await app_settings(session)
was_open, was_quota = before.registration_open, before.default_quota_bytes
user = User(handle=f"{_localpart()}@test.local", password_hash=hash_password(PASSWORD))
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield client, session, user.id
finally:
await client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.execute(
update(AppSettings).values(registration_open=was_open, default_quota_bytes=was_quota)
)
await session.commit()
await session.close()
await engine.dispose()
async def _set_open(session: AsyncSession, value: bool) -> None:
await session.execute(update(AppSettings).values(registration_open=value))
await session.commit()
async def test_register_is_refused_while_closed(
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
) -> None:
client, session, _ = api
await _set_open(session, False)
status = await client.get("/api/auth/registration")
assert status.status_code == 200
assert status.json() == {"open": False, "bootstrap": False}
refused = await client.post(
"/api/auth/register", json={"localpart": _localpart(), "password": PASSWORD}
)
assert refused.status_code == 403
assert refused.json()["error"]["code"] == "registration_closed"
async def test_admin_settings_endpoint_requires_admin(
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
) -> None:
client, session, user_id = api
denied = await client.get("/api/admin/settings")
assert denied.status_code == 403
assert denied.json()["error"]["code"] == "not_admin"
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
await session.commit()
assert (await client.get("/api/admin/settings")).status_code == 200
opened = await client.put(
"/api/admin/settings", json={"registration_open": True}, headers={"X-CSRF": CSRF}
)
assert opened.status_code == 200
assert opened.json()["registration_open"] is True
assert (await client.get("/api/auth/registration")).json()["open"] is True
async def test_me_exposes_admin_flag(
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
) -> None:
client, session, user_id = api
assert (await client.get("/api/auth/me")).json()["is_admin"] is False
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
await session.commit()
assert (await client.get("/api/auth/me")).json()["is_admin"] is True
async def test_settings_row_is_a_singleton(db_session: AsyncSession) -> None:
"""The CHECK constraint is what stops a second row drifting into existence."""
rows = (await db_session.execute(select(AppSettings.id))).scalars().all()
assert rows == [1]
# --- default quota -----------------------------------------------------------
_GIB = 1024**3
async def test_default_quota_applies_only_to_later_users(db_session: AsyncSession) -> None:
"""Raising the default must not resize a mailbox that already exists."""
await db_session.execute(delete(User))
settings = await app_settings(db_session)
settings.default_quota_bytes = 5 * _GIB
await db_session.flush()
first = await register_user(
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
)
assert first.quota_bytes == 5 * _GIB
settings = await app_settings(db_session)
settings.default_quota_bytes = 20 * _GIB
settings.registration_open = True
await db_session.flush()
second = await register_user(
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
)
assert second.quota_bytes == 20 * _GIB
# The whole point: the earlier account is untouched.
await db_session.refresh(first)
assert first.quota_bytes == 5 * _GIB
async def test_admin_can_set_default_quota(
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
) -> None:
client, session, user_id = api
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
await session.commit()
# Establish the value the merge must preserve rather than assuming the
# ambient one — the dev database is shared and its state moves around.
await _set_open(session, True)
saved = await client.put(
"/api/admin/settings",
json={"default_quota_bytes": 10 * _GIB},
headers={"X-CSRF": CSRF},
)
assert saved.status_code == 200
assert saved.json()["default_quota_bytes"] == 10 * _GIB
# A merge, not a replace: the field we left out kept its value.
assert saved.json()["registration_open"] is True
rejected = await client.put(
"/api/admin/settings", json={"default_quota_bytes": 0}, headers={"X-CSRF": CSRF}
)
assert rejected.status_code == 422