Instance owner/operator role (env-declared via OWNER_EMAIL) #240

Merged
justin merged 1 commits from instance-owner into main 2026-06-09 23:17:09 -04:00
15 changed files with 467 additions and 5 deletions
+30
View File
@@ -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)] 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: def get_mailer() -> Mailer:
settings = get_settings() settings = get_settings()
if settings.mailer == "smtp" and settings.smtp_host: if settings.mailer == "smtp" and settings.smtp_host:
+2
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import ( from app.api.v1 import (
admin,
ai, ai,
auth, auth,
citations, citations,
@@ -38,3 +39,4 @@ api_router.include_router(public.router)
api_router.include_router(members.router) api_router.include_router(members.router)
api_router.include_router(proposals.router) api_router.include_router(proposals.router)
api_router.include_router(ai.router) api_router.include_router(ai.router)
api_router.include_router(admin.router)
+38
View File
@@ -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()],
)
+9 -3
View File
@@ -1,15 +1,21 @@
from fastapi import APIRouter, File, Form, Response, UploadFile 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.schemas.user import UserRead, UserSelfPersonUpdate
from app.services import account_service, user_service from app.services import account_service, user_service
router = APIRouter(prefix="/users", tags=["users"]) 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) @router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead: async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current) return _me(current)
@router.patch("/me/self-person", response_model=UserRead) @router.patch("/me/self-person", response_model=UserRead)
@@ -20,7 +26,7 @@ async def set_self_person(
user = await user_service.set_self_person( user = await user_service.set_self_person(
session, user=current, person_id=data.self_person_id session, user=current, person_id=data.self_person_id
) )
return UserRead.model_validate(user) return _me(user)
@router.get("/me/export") @router.get("/me/export")
+12
View File
@@ -22,6 +22,18 @@ class Settings(BaseSettings):
version: str = "0.0.0" version: str = "0.0.0"
app_env: str = Field(default="development", description="development | production") 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 # SQLAlchemy async URL, e.g. postgresql+asyncpg://user:pass@host:5432/db
database_url: str = Field( database_url: str = Field(
default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance", default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance",
+20
View File
@@ -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]
+3
View File
@@ -21,6 +21,9 @@ class UserRead(BaseModel):
email_verified_at: datetime | None email_verified_at: datetime | None
self_person_id: uuid.UUID | None = None self_person_id: uuid.UUID | None = None
created_at: datetime 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): class UserSelfPersonUpdate(BaseModel):
+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
+12
View File
@@ -4,6 +4,18 @@
# --- Core --- # --- Core ---
APP_ENV=development 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) --- # --- Images (pulled from git.jpaul.io; CI pushes to the LAN registry) ---
# test-main = current main build; or pin a semver / test-sha-<sha> for rollback. # test-main = current main build; or pin a semver / test-sha-<sha> for rollback.
IMAGE_TAG=test-main IMAGE_TAG=test-main
+2 -1
View File
@@ -88,7 +88,8 @@ Core entities and the important relationships. (Illustrative, not final DDL.)
### Tenancy & identity ### Tenancy & identity
- **User** — a person with login. Auth method(s) are attached but identity is internal, so one user can link multiple providers. - **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. - **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 ### 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. - **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.
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from "@/components/app-shell";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+103
View File
@@ -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 (
<div className="flex flex-col gap-1">
<div className="text-xs uppercase tracking-wider text-[var(--muted)]">{label}</div>
<div className="text-sm font-medium">{value}</div>
</div>
);
}
export default function AdminPage() {
const [instance, setInstance] = useState<Instance | null>(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 <div className="p-6 text-sm text-[var(--muted)]">Loading</div>;
// 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 (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-xl font-semibold">Instance admin</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
{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."}
</p>
</div>
);
}
const i = instance;
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-xl font-semibold">Instance admin</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Operational status for this deployment. You see this because your account is
named in <code>OWNER_EMAIL</code>. Instance ownership is an operator role it
does not grant access to other people&apos;s private tree data.
</p>
<Card className="mt-6">
<CardContent className="grid grid-cols-2 gap-5 py-6 sm:grid-cols-3">
<Stat label="Version" value={i.version} />
<Stat label="Environment" value={i.env} />
<Stat label="Users" value={i.user_count} />
<Stat label="Trees" value={i.tree_count} />
<Stat
label="Email verification"
value={i.require_email_verification ? "required" : "off"}
/>
<Stat label="Owner(s)" value={i.owner_emails.join(", ") || "—"} />
</CardContent>
</Card>
<Card className="mt-4">
<CardContent className="flex flex-col gap-3 py-6">
<div className="text-sm font-medium">AI providers (instance-wide)</div>
{i.ai_providers.length === 0 ? (
<div className="text-sm text-[var(--muted)]">
None configured. Set provider credentials (Anthropic, OpenAI, x.AI, or
Ollama) in the server environment.
</div>
) : (
<ul className="flex flex-col gap-1 text-sm">
{i.ai_providers.map((p) => (
<li key={p.name} className="flex items-center justify-between">
<span className="font-medium">{p.name}</span>
<span className="text-[var(--muted)]">{p.model}</span>
</li>
))}
</ul>
)}
<div className="text-xs text-[var(--muted)]">
Default provider: {i.default_llm_provider}. Per-tree AI policy is set on
each tree&apos;s AI page.
</div>
</CardContent>
</Card>
</div>
);
}
+14 -1
View File
@@ -12,6 +12,7 @@ import {
LogOut, LogOut,
Network, Network,
Settings, Settings,
ShieldCheck,
Sparkles, Sparkles,
UserPlus, UserPlus,
Users, Users,
@@ -30,7 +31,11 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...] const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null; const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
const [treeName, setTreeName] = useState<string | null>(null); const [treeName, setTreeName] = useState<string | null>(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 [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -98,6 +103,14 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} /> <Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
<Item href="/explore" label="Explore" icon={Compass} active={pathname === "/explore"} /> <Item href="/explore" label="Explore" icon={Compass} active={pathname === "/explore"} />
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} /> <Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
{me?.is_instance_owner && (
<Item
href="/admin"
label="Admin"
icon={ShieldCheck}
active={pathname.startsWith("/admin")}
/>
)}
{treeId && ( {treeId && (
<div className="mt-5 flex flex-col gap-1"> <div className="mt-5 flex flex-col gap-1">
+64
View File
@@ -1049,6 +1049,26 @@ export interface paths {
patch: operations["update_ai_policy_api_v1_trees__tree_id__ai_patch"]; patch: operations["update_ai_policy_api_v1_trees__tree_id__ai_patch"];
trace?: never; 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<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -1418,6 +1438,25 @@ export interface components {
/** Unmapped Tags */ /** Unmapped Tags */
unmapped_tags: string[]; 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 */
LoginRequest: { LoginRequest: {
/** Email */ /** Email */
@@ -1998,6 +2037,11 @@ export interface components {
* Format: date-time * Format: date-time
*/ */
created_at: string; created_at: string;
/**
* Is Instance Owner
* @default false
*/
is_instance_owner?: boolean;
}; };
/** UserSelfPersonUpdate */ /** UserSelfPersonUpdate */
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"];
};
};
};
};
} }
+81
View File
@@ -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": { "components": {
@@ -5399,6 +5421,60 @@
], ],
"title": "ImportReport" "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": { "LoginRequest": {
"properties": { "properties": {
"email": { "email": {
@@ -7194,6 +7270,11 @@
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"title": "Created At" "title": "Created At"
},
"is_instance_owner": {
"type": "boolean",
"title": "Is Instance Owner",
"default": false
} }
}, },
"type": "object", "type": "object",