Add an instance owner/operator role (env-declared via OWNER_EMAIL)

Provenance had no system-level owner: ownership was only per-tree
(TreeMembership), so a self-hosted instance had no operator account and no
instance-admin surface. This adds one, declared by environment per the project's
twelve-factor rule.

- OWNER_EMAIL (comma-separated): the account(s) named here are instance owners.
  Derived at request time — no DB column, no migration, can't drift from the env,
  survives DB resets. is_instance_owner()/InstanceOwner dependency in api/deps.py.
- Ownership requires a VERIFIED email (independent of REQUIRE_EMAIL_VERIFICATION).
  Registration is open, so without this an attacker could seize the role by
  registering the owner address first; verification ties it to inbox control.
- GET /api/v1/admin/instance (owner-only): operational status — version, env,
  user/tree counts, configured AI providers. Deliberately exposes no tree data
  or PII: instance ownership is an operator role, NOT a privacy-engine bypass.
- /users/me reports is_instance_owner; frontend gains an owner-only /admin page
  and a conditional sidebar link (server-enforced, not just client-hidden).

Found-and-fixed by an adversarial security review before merge: the
verified-email land-grab (above) and a frontend null-deref where the admin page
crashed on 401/5xx instead of failing closed.

Docs: .env.example + ARCHITECTURE (notes the not-a-privacy-bypass boundary and
the verified-email requirement). Tests: owner matching, the land-grab guard,
/users/me, and owner-only /admin. Suite 96 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-09 23:16:45 -04:00
parent 6fbad3106d
commit c5631d3eab
15 changed files with 467 additions and 5 deletions
+72
View File
@@ -0,0 +1,72 @@
"""Instance owner (OWNER_EMAIL): the operator account + the owner-only /admin
surface. Ownership is derived from the env at request time — no DB column — and
requires a *verified* email so the owner address can't be land-grabbed by
whoever registers it first."""
from datetime import datetime, timezone
from sqlalchemy import text
from app.api.deps import is_instance_owner
from app.core.config import get_settings
from app.models.user import User
from tests.conftest import auth, register
VERIFIED = datetime(2020, 1, 1, tzinfo=timezone.utc)
def test_is_instance_owner_matches_case_insensitively(monkeypatch):
monkeypatch.setattr(get_settings(), "owner_email", "Owner@Example.com, second@ex.com")
assert is_instance_owner(User(email="owner@example.com", email_verified_at=VERIFIED)) is True
assert is_instance_owner(User(email="SECOND@ex.com", email_verified_at=VERIFIED)) is True
assert is_instance_owner(User(email="nope@ex.com", email_verified_at=VERIFIED)) is False
def test_unverified_owner_email_is_not_owner(monkeypatch):
"""The land-grab guard: a matching email with no verification is NOT owner."""
monkeypatch.setattr(get_settings(), "owner_email", "boss@ex.com")
assert is_instance_owner(User(email="boss@ex.com", email_verified_at=None)) is False
assert is_instance_owner(User(email="boss@ex.com", email_verified_at=VERIFIED)) is True
def test_no_owner_when_unset(monkeypatch):
monkeypatch.setattr(get_settings(), "owner_email", "")
# An empty OWNER_EMAIL designates no owner — and must never match the (also
# empty-string-normalizing) edges.
assert is_instance_owner(User(email="anyone@ex.com", email_verified_at=VERIFIED)) is False
assert is_instance_owner(User(email="", email_verified_at=VERIFIED)) is False
monkeypatch.setattr(get_settings(), "owner_email", " , ")
assert is_instance_owner(User(email="", email_verified_at=VERIFIED)) is False
async def _verify(db_session, email: str) -> None:
await db_session.execute(
text("UPDATE users SET email_verified_at = now() WHERE email = :e"), {"e": email}
)
await db_session.commit()
async def test_me_reports_instance_owner(client, db_session, monkeypatch):
monkeypatch.setattr(get_settings(), "owner_email", "boss@ex.com")
boss = auth(await register(client, "boss@ex.com"))
other = auth(await register(client, "peon@ex.com"))
await _verify(db_session, "boss@ex.com")
assert (await client.get("/api/v1/users/me", headers=boss)).json()["is_instance_owner"] is True
assert (await client.get("/api/v1/users/me", headers=other)).json()["is_instance_owner"] is False
async def test_admin_instance_is_owner_only(client, db_session, monkeypatch):
monkeypatch.setattr(get_settings(), "owner_email", "boss@ex.com")
boss = auth(await register(client, "boss@ex.com"))
other = auth(await register(client, "peon@ex.com"))
await _verify(db_session, "boss@ex.com")
assert (await client.get("/api/v1/admin/instance")).status_code == 401 # anon
assert (await client.get("/api/v1/admin/instance", headers=other)).status_code == 403 # non-owner
r = await client.get("/api/v1/admin/instance", headers=boss)
assert r.status_code == 200
body = r.json()
assert body["owner_emails"] == ["boss@ex.com"]
assert body["user_count"] >= 2
assert "ai_providers" in body and "default_llm_provider" in body