From c5631d3eabcc370b2b5b6b42c92621d584ce1bc2 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 9 Jun 2026 23:16:45 -0400 Subject: [PATCH] Add an instance owner/operator role (env-declared via OWNER_EMAIL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Paul --- backend/app/api/deps.py | 30 ++++++++ backend/app/api/v1/__init__.py | 2 + backend/app/api/v1/admin.py | 38 ++++++++++ backend/app/api/v1/users.py | 12 +++- backend/app/core/config.py | 12 ++++ backend/app/schemas/admin.py | 20 ++++++ backend/app/schemas/user.py | 3 + backend/tests/test_instance_owner.py | 72 +++++++++++++++++++ deploy/.env.example | 12 ++++ docs/ARCHITECTURE.md | 3 +- frontend/app/admin/layout.tsx | 5 ++ frontend/app/admin/page.tsx | 103 +++++++++++++++++++++++++++ frontend/components/app-sidebar.tsx | 15 +++- frontend/lib/api/schema.d.ts | 64 +++++++++++++++++ frontend/openapi.json | 81 +++++++++++++++++++++ 15 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/v1/admin.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/tests/test_instance_owner.py create mode 100644 frontend/app/admin/layout.tsx create mode 100644 frontend/app/admin/page.tsx diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index a502c7e..9df7c76 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -54,6 +54,36 @@ async def get_current_user_or_none(request: Request, session: SessionDep) -> Use CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)] +def is_instance_owner(user: User) -> bool: + """Whether this account is an instance owner/operator — i.e. its email is + named in OWNER_EMAIL *and* that email has been verified. Instance ownership + is an operational/config role; it does NOT bypass the privacy engine or grant + access to others' tree data. + + The verified-email requirement is load-bearing: registration is open and (by + default) doesn't require verification, so without it an attacker could claim + the owner email by registering it before the operator does — a land-grab to + the highest role with no proof of inbox control. Requiring verification ties + ownership to actual control of the named inbox regardless of the global + REQUIRE_EMAIL_VERIFICATION setting. (Self-hosts without SMTP can verify via + the link the console mailer prints to the operator-controlled logs.)""" + owners = get_settings().owner_emails() + return ( + bool(owners) + and user.email_verified_at is not None + and user.email.strip().lower() in owners + ) + + +async def require_instance_owner(current: CurrentUser) -> User: + if not is_instance_owner(current): + raise HTTPException(status.HTTP_403_FORBIDDEN, "instance owner only") + return current + + +InstanceOwner = Annotated[User, Depends(require_instance_owner)] + + def get_mailer() -> Mailer: settings = get_settings() if settings.mailer == "smtp" and settings.smtp_host: diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 57c094f..d73d4f2 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from app.api.v1 import ( + admin, ai, auth, citations, @@ -38,3 +39,4 @@ api_router.include_router(public.router) api_router.include_router(members.router) api_router.include_router(proposals.router) api_router.include_router(ai.router) +api_router.include_router(admin.router) diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py new file mode 100644 index 0000000..3d30bb6 --- /dev/null +++ b/backend/app/api/v1/admin.py @@ -0,0 +1,38 @@ +"""Instance-admin surface — owner-only (OWNER_EMAIL). Operational status and +instance-wide configuration. Deliberately exposes no tree contents or PII: +instance ownership is an operator role, not a privacy bypass.""" + +from sqlalchemy import func, select + +from fastapi import APIRouter + +from app.api.deps import InstanceOwner, SessionDep, configured_llm_providers +from app.core.config import get_settings +from app.models.tree import Tree +from app.models.user import User +from app.schemas.admin import InstanceStatus +from app.schemas.ai_policy import ConfiguredProvider + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/instance", response_model=InstanceStatus) +async def instance_status(owner: InstanceOwner, session: SessionDep) -> InstanceStatus: + """Operator dashboard data. Requires the caller to be an instance owner.""" + s = get_settings() + user_count = await session.scalar( + select(func.count()).select_from(User).where(User.deleted_at.is_(None)) + ) + tree_count = await session.scalar( + select(func.count()).select_from(Tree).where(Tree.deleted_at.is_(None)) + ) + return InstanceStatus( + version=s.version, + env=s.app_env, + owner_emails=sorted(s.owner_emails()), + require_email_verification=s.require_email_verification, + user_count=user_count or 0, + tree_count=tree_count or 0, + default_llm_provider=s.default_llm_provider, + ai_providers=[ConfiguredProvider(**p) for p in configured_llm_providers()], + ) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 0c61ead..c578c5e 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,15 +1,21 @@ from fastapi import APIRouter, File, Form, Response, UploadFile -from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep +from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep, is_instance_owner from app.schemas.user import UserRead, UserSelfPersonUpdate from app.services import account_service, user_service router = APIRouter(prefix="/users", tags=["users"]) +def _me(user) -> UserRead: + out = UserRead.model_validate(user) + out.is_instance_owner = is_instance_owner(user) + return out + + @router.get("/me", response_model=UserRead) async def read_me(current: CurrentUser) -> UserRead: - return UserRead.model_validate(current) + return _me(current) @router.patch("/me/self-person", response_model=UserRead) @@ -20,7 +26,7 @@ async def set_self_person( user = await user_service.set_self_person( session, user=current, person_id=data.self_person_id ) - return UserRead.model_validate(user) + return _me(user) @router.get("/me/export") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3d1e495..4b44d54 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -22,6 +22,18 @@ class Settings(BaseSettings): version: str = "0.0.0" app_env: str = Field(default="development", description="development | production") + # --- Instance owner / operator --- + # Email(s) of the instance owner(s) — the operator(s) who run this server. + # The matching account(s) get instance-admin rights (instance-wide settings; + # see /api/v1/admin). Comma-separated for several. Empty = no designated + # owner (the instance has no operator account). Derived at request time, so + # changing it takes effect immediately with no migration or DB state. + owner_email: str = "" + + def owner_emails(self) -> frozenset[str]: + """Normalized (lowercased, trimmed) owner emails; empty if none set.""" + return frozenset(e.strip().lower() for e in self.owner_email.split(",") if e.strip()) + # SQLAlchemy async URL, e.g. postgresql+asyncpg://user:pass@host:5432/db database_url: str = Field( default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance", diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..84d2c32 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,20 @@ +"""Instance-admin schemas. Operator-facing, owner-only — operational status and +config, never tree data or PII (instance ownership doesn't bypass privacy).""" + +from pydantic import BaseModel + +from app.schemas.ai_policy import ConfiguredProvider + + +class InstanceStatus(BaseModel): + version: str + env: str + # Operator account(s) — the email(s) named in OWNER_EMAIL. + owner_emails: list[str] + require_email_verification: bool + # Aggregate, non-identifying counts (live rows only). + user_count: int + tree_count: int + # Instance-wide AI configuration (no secrets). + default_llm_provider: str + ai_providers: list[ConfiguredProvider] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f3f9e91..df39d50 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -21,6 +21,9 @@ class UserRead(BaseModel): email_verified_at: datetime | None self_person_id: uuid.UUID | None = None created_at: datetime + # Operational role, not a DB column: true when this account's email is named + # in OWNER_EMAIL. Set by the API layer (see users.read_me). + is_instance_owner: bool = False class UserSelfPersonUpdate(BaseModel): diff --git a/backend/tests/test_instance_owner.py b/backend/tests/test_instance_owner.py new file mode 100644 index 0000000..9370da0 --- /dev/null +++ b/backend/tests/test_instance_owner.py @@ -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 diff --git a/deploy/.env.example b/deploy/.env.example index f81ab84..7593c53 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -4,6 +4,18 @@ # --- Core --- APP_ENV=development +# Instance owner / operator. The account(s) whose email is named here get +# instance-admin rights (the owner-only /admin surface, instance-wide settings). +# Comma-separated for several owners. Leave empty for an instance with no +# designated operator. Derived at request time — no migration, takes effect on +# restart. Set this to YOUR account email on a real deployment. +# +# The named account must have a VERIFIED email to be recognized as owner — this +# stops someone from claiming the owner address by registering it before you do. +# Register this email and verify it (via SMTP, or the link the console mailer +# prints to the backend logs) — ideally before exposing registration publicly. +OWNER_EMAIL= + # --- Images (pulled from git.jpaul.io; CI pushes to the LAN registry) --- # test-main = current main build; or pin a semver / test-sha- for rollback. IMAGE_TAG=test-main diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 81ee560..a5ec547 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -88,7 +88,8 @@ Core entities and the important relationships. (Illustrative, not final DDL.) ### Tenancy & identity - **User** — a person with login. Auth method(s) are attached but identity is internal, so one user can link multiple providers. - **Tree** — the top-level tenant boundary for genealogical data. Owned by a User; may have additional members. -- **TreeMembership** — (User, Tree, role) where role ∈ {owner, editor, viewer}. The basis for authorization. +- **TreeMembership** — (User, Tree, role) where role ∈ {owner, editor, viewer}. The basis for authorization *within a tree*. +- **Instance owner / operator** — orthogonal to tree roles. The account(s) whose email is named in the `OWNER_EMAIL` env var **and whose email is verified** are the instance's operator(s), with access to the owner-only `/api/v1/admin` surface (operational status, instance-wide config). Derived from the env at request time — no DB column, no migration, can't drift, survives DB resets. The verified-email requirement is deliberate: registration is open, so without it whoever registers the owner address first would seize the role — verification ties ownership to proven control of the inbox. Crucially this is **not** a privacy bypass: an instance owner gets operational/config rights, **not** read access to other users' private trees or living-person PII — those still resolve only through the privacy engine. (`is_instance_owner` in `api/deps.py`.) ### Genealogical core - **Person** — belongs to a Tree. Has many **Name** records (with parts: given, surname, prefix/suffix, and a type such as birth/married/alias) to support variants and changes over time. Carries living/deceased status. diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx new file mode 100644 index 0000000..0394bc9 --- /dev/null +++ b/frontend/app/admin/layout.tsx @@ -0,0 +1,5 @@ +import { AppShell } from "@/components/app-shell"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..235aa51 --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Card, CardContent } from "@/components/ui/card"; + +type Instance = components["schemas"]["InstanceStatus"]; + +function Stat({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export default function AdminPage() { + const [instance, setInstance] = useState(null); + const [forbidden, setForbidden] = useState(false); + const [ready, setReady] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/v1/admin/instance"); + if (response.status === 403) setForbidden(true); + else if (data) setInstance(data); + setReady(true); + }, []); + + useEffect(() => { + load(); + }, [load]); + + if (!ready) return
Loading…
; + + // Fail closed on anything that isn't a successful owner load: 403 (not owner), + // 401 (not signed in), or any 5xx all land here rather than dereferencing null. + if (forbidden || !instance) { + return ( +
+

Instance admin

+

+ {forbidden + ? "This area is for the instance owner only. Set OWNER_EMAIL in the server environment to your account email (and verify that email) to claim it." + : "Instance status is unavailable right now. Make sure you're signed in as the instance owner."} +

+
+ ); + } + + const i = instance; + return ( +
+

Instance admin

+

+ Operational status for this deployment. You see this because your account is + named in OWNER_EMAIL. Instance ownership is an operator role — it + does not grant access to other people's private tree data. +

+ + + + + + + + + + + + + + +
AI providers (instance-wide)
+ {i.ai_providers.length === 0 ? ( +
+ None configured. Set provider credentials (Anthropic, OpenAI, x.AI, or + Ollama) in the server environment. +
+ ) : ( +
    + {i.ai_providers.map((p) => ( +
  • + {p.name} + {p.model} +
  • + ))} +
+ )} +
+ Default provider: {i.default_llm_provider}. Per-tree AI policy is set on + each tree's AI page. +
+
+
+
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index a27288a..a835942 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -12,6 +12,7 @@ import { LogOut, Network, Settings, + ShieldCheck, Sparkles, UserPlus, Users, @@ -30,7 +31,11 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { const segs = pathname.split("/").filter(Boolean); // ["trees", "", ...] const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null; const [treeName, setTreeName] = useState(null); - const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null); + const [me, setMe] = useState<{ + display_name: string | null; + email: string; + is_instance_owner?: boolean; + } | null>(null); const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); @@ -98,6 +103,14 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { + {me?.is_instance_owner && ( + + )} {treeId && (
diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 21f044b..b3c062c 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -1049,6 +1049,26 @@ export interface paths { patch: operations["update_ai_policy_api_v1_trees__tree_id__ai_patch"]; trace?: never; }; + "/api/v1/admin/instance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Instance Status + * @description Operator dashboard data. Requires the caller to be an instance owner. + */ + get: operations["instance_status_api_v1_admin_instance_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1418,6 +1438,25 @@ export interface components { /** Unmapped Tags */ unmapped_tags: string[]; }; + /** InstanceStatus */ + InstanceStatus: { + /** Version */ + version: string; + /** Env */ + env: string; + /** Owner Emails */ + owner_emails: string[]; + /** Require Email Verification */ + require_email_verification: boolean; + /** User Count */ + user_count: number; + /** Tree Count */ + tree_count: number; + /** Default Llm Provider */ + default_llm_provider: string; + /** Ai Providers */ + ai_providers: components["schemas"]["ConfiguredProvider"][]; + }; /** LoginRequest */ LoginRequest: { /** Email */ @@ -1998,6 +2037,11 @@ export interface components { * Format: date-time */ created_at: string; + /** + * Is Instance Owner + * @default false + */ + is_instance_owner?: boolean; }; /** UserSelfPersonUpdate */ UserSelfPersonUpdate: { @@ -4760,4 +4804,24 @@ export interface operations { }; }; }; + instance_status_api_v1_admin_instance_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InstanceStatus"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index 2180b5e..0ee7e1d 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -4187,6 +4187,28 @@ } } } + }, + "/api/v1/admin/instance": { + "get": { + "tags": [ + "admin" + ], + "summary": "Instance Status", + "description": "Operator dashboard data. Requires the caller to be an instance owner.", + "operationId": "instance_status_api_v1_admin_instance_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStatus" + } + } + } + } + } + } } }, "components": { @@ -5399,6 +5421,60 @@ ], "title": "ImportReport" }, + "InstanceStatus": { + "properties": { + "version": { + "type": "string", + "title": "Version" + }, + "env": { + "type": "string", + "title": "Env" + }, + "owner_emails": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Owner Emails" + }, + "require_email_verification": { + "type": "boolean", + "title": "Require Email Verification" + }, + "user_count": { + "type": "integer", + "title": "User Count" + }, + "tree_count": { + "type": "integer", + "title": "Tree Count" + }, + "default_llm_provider": { + "type": "string", + "title": "Default Llm Provider" + }, + "ai_providers": { + "items": { + "$ref": "#/components/schemas/ConfiguredProvider" + }, + "type": "array", + "title": "Ai Providers" + } + }, + "type": "object", + "required": [ + "version", + "env", + "owner_emails", + "require_email_verification", + "user_count", + "tree_count", + "default_llm_provider", + "ai_providers" + ], + "title": "InstanceStatus" + }, "LoginRequest": { "properties": { "email": { @@ -7194,6 +7270,11 @@ "type": "string", "format": "date-time", "title": "Created At" + }, + "is_instance_owner": { + "type": "boolean", + "title": "Is Instance Owner", + "default": false } }, "type": "object",