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:
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -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'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's AI page.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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", "<id>", ...]
|
||||
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : 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 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="/explore" label="Explore" icon={Compass} active={pathname === "/explore"} />
|
||||
<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 && (
|
||||
<div className="mt-5 flex flex-col gap-1">
|
||||
|
||||
Vendored
+64
@@ -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<string, never>;
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user