Merge origin/main (user accounts) into mobile feature-parity branch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Claude
2026-08-13 13:45:28 +00:00
40 changed files with 3120 additions and 345 deletions
+24 -1
View File
@@ -5,9 +5,32 @@
# Postgres: postgresql+psycopg://user:pass@host:5432/handler # Postgres: postgresql+psycopg://user:pass@host:5432/handler
DATABASE_URL=sqlite:////var/lib/handler/handler.db DATABASE_URL=sqlite:////var/lib/handler/handler.db
# Single global bearer token gating every API route. Required for the API to start. # Legacy/machine bearer token gating every API route. Human operators now sign in with
# email + password (user accounts — first sign-up becomes the admin); this token remains
# for scripts/CI and as a break-glass credential. Optional once accounts exist.
AUTH_TOKEN=change-me-to-a-long-random-string AUTH_TOKEN=change-me-to-a-long-random-string
# ---- User accounts (email + password sign-in for the dashboard/API) ----
# Browser session lifetime and one-shot link validity. Defaults shown.
# SESSION_TTL_DAYS=30
# RESET_TOKEN_TTL_HOURS=2
# INVITE_TOKEN_TTL_HOURS=168
# Outbound email for invite + password-reset links. Leave SMTP_HOST unset to run without
# email: invite/reset links are then shown to the admin in the dashboard instead of
# mailed, and self-serve "forgot password" tells users to ask an admin.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USERNAME=
# SMTP_PASSWORD=
# SMTP_FROM=handler@example.com
# SMTP_STARTTLS=true # STARTTLS on port 587 (the common setup)
# SMTP_SSL=false # implicit TLS on port 465 instead
# Base URL the emailed links point at, e.g. https://handler.example.com. Falls back to
# each request's own origin when unset (right for the same-origin bundled UI).
# PUBLIC_BASE_URL=
# Optional higher-trust token gating PUT /shared/context/:key. # Optional higher-trust token gating PUT /shared/context/:key.
# Falls back to AUTH_TOKEN if unset. # Falls back to AUTH_TOKEN if unset.
# SHARED_CONTEXT_WRITE_TOKEN= # SHARED_CONTEXT_WRITE_TOKEN=
+46
View File
@@ -25,6 +25,52 @@ since its last release:
kind filters and expandable notes showing body, tags, and links; authoring stays on kind filters and expandable notes showing body, tags, and links; authoring stays on
the web dashboard and the agents' MCP server. the web dashboard and the agents' MCP server.
### Added — user accounts: email sign-in, invites, resets, per-user separation
- **Email + password accounts** replace "know the API key" for humans. First run shows
a setup form and the **first account created is the admin**; every later account is
**invited by an admin** through a one-shot set-password link. Passwords are scrypt
(stdlib, self-describing hashes); sessions are opaque bearer tokens stored only as
SHA-256 with a configurable TTL.
- **Password reset by email** (`POST /auth/forgot` → short-lived link, silent about
account existence) via plain SMTP (`SMTP_*` settings). **Email is optional**: with
SMTP unset, invite/reset links are shown to the admin in the dashboard to hand over
out-of-band. Spending a link revokes the account's existing sessions.
- **Per-user separation of projects, skills, and tools.** Projects, skills, MCP
connectors, plugins, and model backends gain an owner; users see **shared + their
own** (foreign resources 404 — existence isn't leaked), owners operate their own
projects end-to-end without admin, shared (unowned) rows stay admin-managed and
visible to all. Launches materialize only the project owner's skills/connectors, and
private model backends can't be picked for someone else's spawns or schedules.
Deleting a user reassigns their resources to shared; admins can reassign owners.
- **Users page** in the dashboard (admin-only): invite, admin/disable toggles, reset
links, delete. Sign-in page gains first-run setup, forgot-password, and a raw
API-token fallback; `/reset` is the public landing page for invite/reset links.
- **Admin safety rails**: the last active admin can't be demoted/disabled/deleted; no
self-deletion. `AUTH_TOKEN`/`ADMIN_TOKEN`/`SHARED_CONTEXT_WRITE_TOKEN` keep their
exact historical semantics for scripts/CI and break-glass.
- 24 new tests (auth flows + separation matrix; 397 total).
### Database (user accounts)
- Migration **`0016_user_accounts`**: new `users`, `auth_sessions`, `auth_tokens`
tables plus a nullable `owner_user_id` on `projects`, `claude_skills`,
`claude_connectors`, `claude_plugins`, `claude_models`. Purely additive; existing
rows have no owner (= shared) so an upgraded deployment behaves exactly as before
until accounts are created.
### Deployment notes (user accounts rollout)
1. Apply migrations as usual (the API container runs them on start).
2. Optionally set `SMTP_HOST`/`SMTP_PORT`/`SMTP_USERNAME`/`SMTP_PASSWORD`/`SMTP_FROM`
(+ `SMTP_STARTTLS`/`SMTP_SSL`) and `PUBLIC_BASE_URL` for emailed links; without
them, invite/reset links appear in the dashboard instead.
3. Open the dashboard and create the first account — it becomes the admin. Existing
`AUTH_TOKEN`-based scripts keep working unchanged; the token can be rotated or
dropped once accounts exist (keep one as break-glass if you like).
4. New TTL knobs (optional): `SESSION_TTL_DAYS=30`, `RESET_TOKEN_TTL_HOURS=2`,
`INVITE_TOKEN_TTL_HOURS=168`.
### Added — the pi harness for local models ([#29](https://github.com/0xWheatyz/handler/pull/29)) ### Added — the pi harness for local models ([#29](https://github.com/0xWheatyz/handler/pull/29))
- **`harness` on model backends** (`claude` | `pi`, default `claude`). A backend row can - **`harness` on model backends** (`claude` | `pi`, default `claude`). A backend row can
+65 -3
View File
@@ -126,9 +126,12 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example)
| Variable | Purpose | Default | | Variable | Purpose | Default |
|---|---|---| |---|---|---|
| `DATABASE_URL` | `sqlite:////abs/path.db` or `postgresql+psycopg://…` | `sqlite:///./handler.db` | | `DATABASE_URL` | `sqlite:////abs/path.db` or `postgresql+psycopg://…` | `sqlite:///./handler.db` |
| `AUTH_TOKEN` | Global bearer token gating every API route | *(required for the API)* | | `AUTH_TOKEN` | Legacy/machine bearer token (scripts, CI, break-glass) — humans sign in with email + password instead ([user accounts](#user-accounts--sign-in)) | unset → env-token auth off |
| `SHARED_CONTEXT_WRITE_TOKEN` | Higher-trust token gating `PUT /shared/context/:key` | falls back to `AUTH_TOKEN` | | `SHARED_CONTEXT_WRITE_TOKEN` | Higher-trust token gating `PUT /shared/context/:key` | falls back to `AUTH_TOKEN` |
| `ADMIN_TOKEN` | Gates the web control surface (enqueue commands, project/host CRUD, credential edits) | falls back to `AUTH_TOKEN` | | `ADMIN_TOKEN` | Admin-level env token (enqueue commands, project/host CRUD, credential edits) | falls back to `AUTH_TOKEN` |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_STARTTLS` / `SMTP_SSL` | Outbound email for invite + password-reset links | unset → links shown to the admin instead of mailed |
| `PUBLIC_BASE_URL` | Base URL emailed links point at | unset → the request's own origin |
| `SESSION_TTL_DAYS` / `RESET_TOKEN_TTL_HOURS` / `INVITE_TOKEN_TTL_HOURS` | Session and one-shot-link lifetimes | `30` / `2` / `168` |
| `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op | | `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op |
| `SEARXNG_URL` / `BRAVE_SEARCH_API_KEY` | Provider for the agents' `web_search` tool (pi harness) | unset → DuckDuckGo fallback | | `SEARXNG_URL` / `BRAVE_SEARCH_API_KEY` | Provider for the agents' `web_search` tool (pi harness) | unset → DuckDuckGo fallback |
| `HANDLER_SECRET_KEY` | Fernet key encrypting git-server tokens + SSH keys at rest (set the same value on API and control) | unset → secret store disabled | | `HANDLER_SECRET_KEY` | Fernet key encrypting git-server tokens + SSH keys at rest (set the same value on API and control) | unset → secret store disabled |
@@ -204,6 +207,56 @@ docker compose run --rm control handler list
docker compose run --rm control handler spawn --project leeworks-api --name junior --task "…" docker compose run --rm control handler spawn --project leeworks-api --name junior --task "…"
``` ```
## User accounts & sign-in
Humans no longer need to know an API key. The dashboard signs in with **email +
password**, and the accounts model is deliberately small-team-shaped:
- **First run**: with zero accounts, the sign-in page becomes a setup form. The first
account created **is the admin**. (`POST /auth/setup` refuses once any account exists.)
- **Everyone else is invited by an admin** (Users page / `POST /auth/users`): creating a
user mints a one-shot **invite link** through which the invitee sets their own
password. With SMTP configured the link is emailed; either way it is shown to the
admin, so email is optional infrastructure, not a requirement.
- **Password reset by email**: "Forgot password?" mails a short-lived reset link
(`POST /auth/forgot` — silent about whether the address exists). Without SMTP, an
admin mints a reset link from the Users page instead. Spending a link revokes every
existing session for that account.
- **Sessions** are opaque bearer tokens (only their SHA-256 is stored), sent exactly
like the old token: `Authorization: Bearer …`. `POST /auth/logout` revokes one;
changing a password revokes the rest.
- **Admin safety rails**: the last active admin can't be demoted, disabled, or deleted;
you can't delete your own account.
### Per-user separation
Every project, skill, MCP connector, plugin, and model backend is either **owned** by
one user or **shared** (no owner). The rules, everywhere:
- A user sees **shared + their own** — another user's resources don't exist for them
(listings filter, direct lookups 404, so existence isn't leaked).
- Creating a resource makes you its owner; owners manage their own resources without
admin help (spawn/kill agents, schedules, approvals, sync, memory notes — everything
project-nested follows the project's owner).
- **Shared resources are admin-managed** and behave exactly like the pre-accounts world:
visible to all, editable by admins. Legacy rows all land here on upgrade, so nothing
changes until people start owning things.
- At **launch**, an agent gets only what its project's owner can see: their skills +
connectors + the shared set. One user's tools never reach another user's agents, and
a private model backend can't be selected for someone else's spawn or schedule.
- Deleting a user **reassigns their resources to shared** (never orphans or deletes
work); an admin can also reassign a project's owner explicitly (`PATCH /projects/:p`).
- Global infrastructure stays admin-only: git servers, the Claude account login,
permission overrides, global memory notes, and user management itself.
### Legacy env tokens
`AUTH_TOKEN` / `ADMIN_TOKEN` / `SHARED_CONTEXT_WRITE_TOKEN` keep working with their
historical semantics (see-everything machine credentials; the admin token passes admin
gates). They're the right tool for scripts and CI — and the break-glass if every admin
is locked out. Resources they create are shared. The dashboard's sign-in page keeps a
"Use an API token" fallback for token-only deployments.
## Web management ## Web management
The dashboard (and the API under it) manages everything — git credentials & hosts, The dashboard (and the API under it) manages everything — git credentials & hosts,
@@ -350,10 +403,19 @@ agent's identity and `DATABASE_URL` injected into its environment. `--role`
## API reference ## API reference
All routes require `Authorization: Bearer <AUTH_TOKEN>`. `GET /health` is unauthenticated. All routes require `Authorization: Bearer <token>` — a user session token from
`POST /auth/login` or a legacy env token. `GET /health`, `GET /auth/status`, and the
account bootstrap routes (`setup`/`login`/`forgot`/`reset`) are unauthenticated.
| Method & path | Purpose | | Method & path | Purpose |
|---|---| |---|---|
| `GET /auth/status` | `{initialized, smtp_configured}` — drives the setup-vs-signin page |
| `POST /auth/setup` | Create the first account (becomes the admin) |
| `POST /auth/login` · `POST /auth/logout` | Session lifecycle (opaque bearer, hash-stored) |
| `GET /auth/me` · `POST /auth/change-password` | Who am I / rotate my password |
| `POST /auth/forgot` · `POST /auth/reset` | Email reset link / spend a reset or invite link |
| `GET`/`POST /auth/users` · `PATCH`/`DELETE /auth/users/:id` | Admin user management (invite links) |
| `POST /auth/users/:id/reset-link` | Admin-minted reset/invite link (the no-SMTP path) |
| `GET /projects` · `POST /projects` | List / register projects | | `GET /projects` · `POST /projects` | List / register projects |
| `GET /projects/:p/agents` · `POST …` | List / register agents (project-scoped) | | `GET /projects/:p/agents` · `POST …` | List / register agents (project-scoped) |
| `GET /projects/:p/agents/:name/checkmark` | The agent's current-state checkmark | | `GET /projects/:p/agents/:name/checkmark` | The agent's current-state checkmark |
+15
View File
@@ -392,6 +392,21 @@ a:hover {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* Inline text button (the auth gate's "Forgot password?" / mode switches). */
.btn-link {
background: none;
border: none;
padding: 0;
color: var(--lw-blue-200, #90cdf4);
font-size: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link:hover {
opacity: 0.85;
}
/* ---------------- Callout ---------------- */ /* ---------------- Callout ---------------- */
.callout { .callout {
border-radius: var(--radius-md); border-radius: var(--radius-md);
+99
View File
@@ -0,0 +1,99 @@
/* Public set-password page — where invite and reset links land (/reset?token=…).
* Outside the auth gate by design: the person arriving here has no session yet.
* Success stores the fresh session token and drops the user into the dashboard. */
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authApi, type ApiError, type SessionResponse } from "@/lib/api";
function ResetForm() {
const params = useSearchParams();
const router = useRouter();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("Passwords don't match.");
return;
}
setBusy(true);
try {
const session = await authApi<SessionResponse>("/auth/reset", { token, password });
window.localStorage.setItem("handler_token", session.token);
router.replace("/");
} catch (err) {
setError((err as ApiError).message || "Something went wrong.");
} finally {
setBusy(false);
}
};
return (
<div className="gate">
<form className="gate-card" onSubmit={submit}>
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
{token ? (
<>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
Choose a password for your account. The link you followed is one-shot once
set, sign in with your email and this password.
</p>
<input
className="input"
type="password"
autoComplete="new-password"
placeholder="New password (8+ characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
autoFocus
required
/>
<input
className="input"
type="password"
autoComplete="new-password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
minLength={8}
required
/>
{error && (
<p className="callout callout-danger" style={{ margin: 0 }}>
{error}
</p>
)}
<button className="btn btn-primary" type="submit" disabled={busy}>
Set password and sign in
</button>
</>
) : (
<p className="callout callout-danger" style={{ margin: 0 }}>
This page needs a reset link (/reset?token=). Ask an admin for one, or use
Forgot password? on the sign-in page.
</p>
)}
</form>
</div>
);
}
export default function ResetPage() {
// useSearchParams requires a Suspense boundary under the static export.
return (
<Suspense fallback={null}>
<ResetForm />
</Suspense>
);
}
+12
View File
@@ -0,0 +1,12 @@
/* Users page (admin only — the sidebar hides it otherwise; the API enforces it). */
"use client";
import { UsersSection } from "@/components/sections/UsersSection";
export default function UsersPage() {
return (
<div className="main-scroll">
<UsersSection />
</div>
);
}
+30 -6
View File
@@ -1,15 +1,18 @@
/* Auth frame: token gate → shell. Lives in the root layout so it wraps every page and /* Auth frame: sign-in gate → shell. Lives in the root layout so it wraps every page and
* persists across client-side navigation. Client-only; the exported HTML is a shell and * persists across client-side navigation. Client-only; the exported HTML is a shell and
* every byte of data is fetched by the browser from the authed API after the token is * every byte of data is fetched by the browser from the authed API after sign-in. The
* supplied. A 401 from any call clears the token and re-prompts with an error. */ * bearer is a user session token from /auth/login (or a raw legacy API token via the
* gate's fallback) — either way it rides Authorization on every call, and a 401 clears
* it and re-prompts. The /reset route is public (it's how invite/reset links land). */
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { DashboardProvider } from "@/components/store"; import { DashboardProvider } from "@/components/store";
import { Shell } from "@/components/Shell"; import { Shell } from "@/components/Shell";
import { TokenGate } from "@/components/TokenGate"; import { AuthGate } from "@/components/AuthGate";
import { sectionFromPath } from "@/lib/nav"; import { sectionFromPath } from "@/lib/nav";
import { type SessionResponse } from "@/lib/api";
const TOKEN_KEY = "handler_token"; const TOKEN_KEY = "handler_token";
@@ -30,7 +33,22 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
setToken(t); setToken(t);
}, []); }, []);
const onSession = useCallback(
(s: SessionResponse) => {
saveToken(s.token);
},
[saveToken],
);
const signOut = useCallback(() => { const signOut = useCallback(() => {
const t = window.localStorage.getItem(TOKEN_KEY);
if (t) {
// Best-effort server-side revocation; a legacy env token treats this as a no-op.
void fetch("/auth/logout", {
method: "POST",
headers: { Authorization: `Bearer ${t}` },
}).catch(() => undefined);
}
window.localStorage.removeItem(TOKEN_KEY); window.localStorage.removeItem(TOKEN_KEY);
setToken(null); setToken(null);
}, []); }, []);
@@ -38,11 +56,17 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
const onUnauthorized = useCallback(() => { const onUnauthorized = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY); window.localStorage.removeItem(TOKEN_KEY);
setToken(null); setToken(null);
setError("Invalid token — please try again."); setError("Session expired or token rejected — please sign in again.");
}, []); }, []);
// Invite/reset links must render without a session — that's their whole point.
const isPublicRoute = pathname.replace(/\/+$/, "") === "/reset";
if (isPublicRoute) {
return <>{children}</>;
}
if (!token) { if (!token) {
return <TokenGate error={error} onSubmit={saveToken} />; return <AuthGate error={error} onSession={onSession} onToken={saveToken} />;
} }
return ( return (
+211
View File
@@ -0,0 +1,211 @@
/* Auth gate: email + password sign-in, shown until a session exists. On a fresh
* deployment (no accounts yet) it becomes the first-run setup form — the account
* created there is the admin. A collapsible fallback still accepts a raw API token
* for headless/legacy setups. Holds no data; the session token lives in localStorage. */
"use client";
import { useEffect, useState } from "react";
import { authApi, type ApiError, type AuthStatus, type SessionResponse } from "@/lib/api";
type Mode = "loading" | "setup" | "login" | "forgot" | "token";
export function AuthGate({
error,
onSession,
onToken,
}: {
error?: string;
/* A fresh session from login/setup: token + user. */
onSession: (s: SessionResponse) => void;
/* Raw API-token fallback (legacy/scripts). */
onToken: (token: string) => void;
}) {
const [mode, setMode] = useState<Mode>("loading");
const [status, setStatus] = useState<AuthStatus | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [rawToken, setRawToken] = useState("");
const [message, setMessage] = useState("");
const [formError, setFormError] = useState(error ?? "");
const [busy, setBusy] = useState(false);
useEffect(() => {
authApi<AuthStatus>("/auth/status")
.then((s) => {
setStatus(s);
setMode(s.initialized ? "login" : "setup");
})
.catch(() => {
// API unreachable or very old server — fall back to the raw token prompt.
setMode("token");
});
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
setMessage("");
if (mode === "token") {
if (rawToken.trim()) onToken(rawToken.trim());
return;
}
setBusy(true);
try {
if (mode === "setup") {
if (password !== confirm) {
setFormError("Passwords don't match.");
return;
}
onSession(await authApi<SessionResponse>("/auth/setup", { email, password }));
} else if (mode === "login") {
onSession(await authApi<SessionResponse>("/auth/login", { email, password }));
} else if (mode === "forgot") {
const r = await authApi<{ ok: boolean; emailed: boolean }>("/auth/forgot", { email });
setMessage(
r.emailed
? "If that address has an account, a reset link is on its way."
: "Email isn't configured on this deployment — ask an admin to generate a reset link for you.",
);
}
} catch (err) {
setFormError((err as ApiError).message || "Something went wrong.");
} finally {
setBusy(false);
}
};
if (mode === "loading") {
return (
<div className="gate">
<div className="gate-card">
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
Loading
</p>
</div>
</div>
);
}
const heading =
mode === "setup"
? "Welcome — create the first account. It becomes the admin; everyone else is invited by you."
: mode === "forgot"
? "Enter your account email and we'll send a password reset link."
: mode === "token"
? "Paste a raw API token (legacy / script access)."
: "Sign in with your email and password.";
return (
<div className="gate">
<form className="gate-card" onSubmit={submit}>
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
{heading}
</p>
{mode !== "token" && (
<input
className="input"
type="email"
autoComplete="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
required
/>
)}
{(mode === "login" || mode === "setup") && (
<input
className="input"
type="password"
autoComplete={mode === "setup" ? "new-password" : "current-password"}
placeholder={mode === "setup" ? "Password (8+ characters)" : "Password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
)}
{mode === "setup" && (
<input
className="input"
type="password"
autoComplete="new-password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
minLength={8}
required
/>
)}
{mode === "token" && (
<input
className="input"
type="password"
autoComplete="off"
placeholder="API token"
value={rawToken}
onChange={(e) => setRawToken(e.target.value)}
autoFocus
/>
)}
{formError && (
<p className="callout callout-danger" style={{ margin: 0 }}>
{formError}
</p>
)}
{message && (
<p className="callout callout-info" style={{ margin: 0 }}>
{message}
</p>
)}
<button className="btn btn-primary" type="submit" disabled={busy}>
{mode === "setup"
? "Create admin account"
: mode === "forgot"
? "Send reset link"
: mode === "token"
? "Continue"
: "Sign in"}
</button>
<div
className="hstack muted"
style={{ justifyContent: "space-between", fontSize: "var(--text-xs)" }}
>
{mode === "login" && (
<button type="button" className="btn-link" onClick={() => setMode("forgot")}>
Forgot password?
</button>
)}
{(mode === "forgot" || mode === "token") && status?.initialized !== false && (
<button type="button" className="btn-link" onClick={() => setMode("login")}>
Back to sign in
</button>
)}
{mode !== "token" && (
<button type="button" className="btn-link" onClick={() => setMode("token")}>
Use an API token
</button>
)}
{mode === "token" && status?.initialized === false && (
<button type="button" className="btn-link" onClick={() => setMode("setup")}>
Back to setup
</button>
)}
</div>
</form>
</div>
);
}
+22 -2
View File
@@ -35,6 +35,7 @@ const BADGES: Partial<Record<Section, { count: (s: Store) => number; accent?: (s
// Draw the eye to it until Claude is logged in on the host this session. // Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done", accent: (s) => s.claudeLogin.status !== "done",
}, },
users: { count: (s) => s.users.length },
}; };
export function Shell({ onSignOut, children }: { onSignOut: () => void; children: React.ReactNode }) { export function Shell({ onSignOut, children }: { onSignOut: () => void; children: React.ReactNode }) {
@@ -55,7 +56,7 @@ export function Shell({ onSignOut, children }: { onSignOut: () => void; children
<span className="logo" /> <span className="logo" />
Claude Monitor Claude Monitor
</div> </div>
{NAV_ROUTES.map((n) => { {NAV_ROUTES.filter((n) => n.key !== "users" || s.me?.is_admin).map((n) => {
const badge = BADGES[n.key]; const badge = BADGES[n.key];
const c = badge?.count(s) ?? 0; const c = badge?.count(s) ?? 0;
const isAccent = badge?.accent?.(s) ?? false; const isAccent = badge?.accent?.(s) ?? false;
@@ -74,11 +75,30 @@ export function Shell({ onSignOut, children }: { onSignOut: () => void; children
})} })}
<div className="sidebar-spacer" /> <div className="sidebar-spacer" />
<div className="sidebar-foot"> <div className="sidebar-foot">
{s.me && (
<div
className="nav-item"
style={{ cursor: "default", opacity: 0.8 }}
title={s.me.kind === "token" ? "Authenticated with an API token" : "Signed in"}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "var(--text-xs)",
}}
>
{s.me.kind === "token" ? "API token" : s.me.email}
{s.me.is_admin ? " · admin" : ""}
</span>
</div>
)}
<button className="nav-item" onClick={s.refresh} title="Refresh now"> <button className="nav-item" onClick={s.refresh} title="Refresh now">
<span>Refresh</span> <span>Refresh</span>
<span className="count"></span> <span className="count"></span>
</button> </button>
<button className="nav-item" onClick={onSignOut} title="Sign out / change token"> <button className="nav-item" onClick={onSignOut} title="Sign out">
<span>Sign out</span> <span>Sign out</span>
</button> </button>
</div> </div>
-48
View File
@@ -1,48 +0,0 @@
/* Token gate: shown until an API token is supplied. Holds no data. Management actions
* (spawn, approve, edit repos/servers) need the admin token; read-only views need the
* plain auth token. The token lives only in localStorage on this device. */
"use client";
import { useState } from "react";
export function TokenGate({ error, onSubmit }: { error?: string; onSubmit: (token: string) => void }) {
const [value, setValue] = useState("");
const submit = (e: React.FormEvent) => {
e.preventDefault();
const t = value.trim();
if (t) onSubmit(t);
};
return (
<div className="gate">
<form className="gate-card" onSubmit={submit}>
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
Paste your API token to continue. Management actions require the admin token; read-only
views work with the plain auth token.
</p>
<input
className="input"
type="password"
autoComplete="current-password"
placeholder="API token"
value={value}
onChange={(e) => setValue(e.target.value)}
autoFocus
/>
{error && (
<p className="callout callout-danger" style={{ margin: 0 }}>
{error}
</p>
)}
<button className="btn btn-primary" type="submit">
Continue
</button>
</form>
</div>
);
}
@@ -0,0 +1,161 @@
/* Users — admin-only account management. Invite by email (the invitee sets their own
* password through a one-shot link, emailed when SMTP is configured and always shown
* here), toggle admin, disable, delete, and mint reset links. Deleting a user turns
* their projects/skills/tools into shared resources rather than removing them. */
"use client";
import { useState } from "react";
import { useDashboard } from "@/components/store";
import { Button } from "@/components/ui";
import { fmtFull } from "@/lib/format";
export function UsersSection() {
const s = useDashboard();
const [email, setEmail] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [lastLink, setLastLink] = useState<{ email: string; url: string } | null>(null);
const invite = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
const created = await s.createUser(email, isAdmin);
if (created) {
setLastLink({ email: created.user.email, url: created.invite_url });
setEmail("");
setIsAdmin(false);
}
};
const resetLink = async (id: number, userEmail: string) => {
const link = await s.mintResetLink(id);
if (link) setLastLink({ email: userEmail, url: link.reset_url });
};
const isSelf = (id: number) => s.me?.kind === "user" && s.me.user_id === id;
return (
<>
<div className="section-head">
<div className="section-title">Users</div>
<div className="section-desc">
Accounts for this Handler. Each user&apos;s projects, skills, and tools are
theirs alone; shared (unowned) resources are visible to everyone and managed by
admins.
</div>
</div>
<div className="section-body vstack" style={{ gap: 16 }}>
<form className="hstack" style={{ gap: 8, flexWrap: "wrap" }} onSubmit={invite}>
<input
className="input"
type="email"
placeholder="new-user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ maxWidth: 320 }}
required
/>
<label className="hstack muted" style={{ gap: 6, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
/>
admin
</label>
<Button type="submit" disabled={s.cmd.busy}>
Invite user
</Button>
</form>
{lastLink && (
<div className="callout callout-info vstack" style={{ gap: 6 }}>
<span>
One-shot set-password link for <strong>{lastLink.email}</strong> (share it over
a channel you trust; it expires):
</span>
<code
className="mono"
style={{ wordBreak: "break-all", userSelect: "all", fontSize: "var(--text-xs)" }}
>
{lastLink.url}
</code>
</div>
)}
{s.users.length === 0 ? (
<div className="empty">No users loaded (admin access required).</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{s.users.map((u) => (
<tr key={u.id}>
<td className="mono">
{u.email}
{isSelf(u.id) ? <span className="faint"> (you)</span> : null}
</td>
<td>{u.is_admin ? "admin" : "user"}</td>
<td className={u.disabled ? "faint" : undefined}>
{u.disabled
? "disabled"
: u.has_password
? "active"
: "invited — awaiting password"}
</td>
<td className="faint nowrap">{fmtFull(u.created_at)}</td>
<td>
<div className="hstack" style={{ gap: 6, justifyContent: "flex-end" }}>
<Button
variant="secondary"
onClick={() => void s.updateUser(u.id, { is_admin: !u.is_admin })}
>
{u.is_admin ? "Demote" : "Make admin"}
</Button>
<Button
variant="secondary"
onClick={() => void s.updateUser(u.id, { disabled: !u.disabled })}
>
{u.disabled ? "Enable" : "Disable"}
</Button>
<Button
variant="secondary"
onClick={() => void resetLink(u.id, u.email)}
>
Reset link
</Button>
<Button
variant="danger"
disabled={isSelf(u.id)}
onClick={() => {
if (
window.confirm(
`Remove ${u.email}? Their projects, skills, and tools become shared.`,
)
) {
void s.deleteUser(u.id);
}
}}
>
Remove
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
+128 -3
View File
@@ -29,11 +29,15 @@ import {
type Command, type Command,
type Host, type Host,
type LogEntry, type LogEntry,
type Me,
type MemoryGraph, type MemoryGraph,
type NoteKind, type NoteKind,
type Project, type Project,
type ResetLink,
type Schedule, type Schedule,
type SharedContext, type SharedContext,
type User,
type UserCreated,
} from "@/lib/api"; } from "@/lib/api";
export type Section = export type Section =
@@ -46,7 +50,8 @@ export type Section =
| "activity" | "activity"
| "shared" | "shared"
| "memory" | "memory"
| "claude"; | "claude"
| "users";
/* The claude web-login flow, driven through the login_start / login_submit commands. /* The claude web-login flow, driven through the login_start / login_submit commands.
* idle → starting → awaiting (have URL) → submitting → done | error */ * idle → starting → awaiting (have URL) → submitting → done | error */
@@ -108,6 +113,17 @@ interface StoreValue {
lastError: string; lastError: string;
loading: boolean; loading: boolean;
/* Who this session belongs to (null until /auth/me answers). Legacy env tokens come
* back as kind "token"; admin-ness drives the Users nav and admin-only controls. */
me: Me | null;
/* User accounts (admin only; empty for everyone else). */
users: User[];
createUser: (email: string, isAdmin: boolean) => Promise<UserCreated | null>;
updateUser: (id: number, b: { is_admin?: boolean; disabled?: boolean }) => Promise<boolean>;
deleteUser: (id: number) => Promise<void>;
mintResetLink: (id: number) => Promise<ResetLink | null>;
refresh: () => void; refresh: () => void;
// actions // actions
@@ -338,6 +354,8 @@ export function DashboardProvider({
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]); const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null); const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
const [claudeModels, setClaudeModels] = useState<ClaudeModel[]>([]); const [claudeModels, setClaudeModels] = useState<ClaudeModel[]>([]);
const [me, setMe] = useState<Me | null>(null);
const [users, setUsers] = useState<User[]>([]);
// Keep polling loop reading fresh values without re-subscribing every render. // Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section); const sectionRef = useRef(section);
@@ -494,6 +512,23 @@ export function DashboardProvider({
} }
}, []); }, []);
const loadMe = useCallback(async () => {
try {
setMe(await clientRef.current.api<Me>("/auth/me"));
} catch (e) {
if (!(e instanceof AuthError)) setMe(null);
}
}, []);
const loadUsers = useCallback(async () => {
try {
setUsers(await clientRef.current.api<User[]>("/auth/users"));
} catch {
// Non-admins get a 403 here; the section is hidden for them anyway.
setUsers([]);
}
}, []);
const loadClaude = useCallback(async () => { const loadClaude = useCallback(async () => {
try { try {
const [skills, connectors, plugins, permissions] = await Promise.all([ const [skills, connectors, plugins, permissions] = await Promise.all([
@@ -538,10 +573,15 @@ export function DashboardProvider({
if (s === "shared") await loadShared(); if (s === "shared") await loadShared();
if (s === "memory") await loadMemory(); if (s === "memory") await loadMemory();
if (s === "claude") await loadClaude(); if (s === "claude") await loadClaude();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels]); if (s === "users") await loadUsers();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels, loadUsers]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the // Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval. // active section) up front, so the Runs inbox is filled without waiting a poll interval.
useEffect(() => {
void loadMe();
}, [loadMe]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
(async () => { (async () => {
@@ -570,8 +610,9 @@ export function DashboardProvider({
if (s === "shared") void loadShared(); if (s === "shared") void loadShared();
if (s === "memory") void loadMemory(); if (s === "memory") void loadMemory();
if (s === "claude") void loadClaude(); if (s === "claude") void loadClaude();
if (s === "users") void loadUsers();
}, },
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels], [loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels, loadUsers],
); );
const selectProject = useCallback( const selectProject = useCallback(
@@ -1400,6 +1441,84 @@ export function DashboardProvider({
[memoryWrite], [memoryWrite],
); );
// ---- user accounts (admin management; direct writes like the Claude pages) ----
const createUser = useCallback(
async (email: string, isAdmin: boolean): Promise<UserCreated | null> => {
try {
const created = await clientRef.current.api<UserCreated>("/auth/users", {
method: "POST",
body: { email: email.trim(), is_admin: isAdmin },
});
setCmd({
text: created.emailed
? `invited ${created.user.email} — an email with their set-password link is on its way`
: `invited ${created.user.email} — email is not configured, hand them the link below`,
error: false,
busy: false,
});
await loadUsers();
return created;
} catch (e) {
if (e instanceof AuthError) return null;
setCmd({ text: (e as Error).message, error: true, busy: false });
return null;
}
},
[loadUsers],
);
const updateUser = useCallback(
async (id: number, b: { is_admin?: boolean; disabled?: boolean }) => {
try {
await clientRef.current.api(`/auth/users/${id}`, { method: "PATCH", body: b });
await loadUsers();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadUsers],
);
const deleteUser = useCallback(
async (id: number) => {
try {
const r = await clientRef.current.api<{ deleted: string; note: string }>(
`/auth/users/${id}`,
{ method: "DELETE" },
);
setCmd({ text: `removed ${r.deleted}${r.note}`, error: false, busy: false });
await loadUsers();
} catch (e) {
if (e instanceof AuthError) return;
setCmd({ text: (e as Error).message, error: true, busy: false });
}
},
[loadUsers],
);
const mintResetLink = useCallback(async (id: number): Promise<ResetLink | null> => {
try {
const link = await clientRef.current.api<ResetLink>(`/auth/users/${id}/reset-link`, {
method: "POST",
});
setCmd({
text: link.emailed
? "reset link emailed to the user (also shown below)"
: "reset link generated — email is not configured, hand it over yourself",
error: false,
busy: false,
});
return link;
} catch (e) {
if (e instanceof AuthError) return null;
setCmd({ text: (e as Error).message, error: true, busy: false });
return null;
}
}, []);
const value: StoreValue = { const value: StoreValue = {
section, section,
setSection, setSection,
@@ -1424,6 +1543,12 @@ export function DashboardProvider({
cmd, cmd,
lastError, lastError,
loading, loading,
me,
users,
createUser,
updateUser,
deleteUser,
mintResetLink,
refresh, refresh,
spawnAgent, spawnAgent,
killAgent, killAgent,
+71
View File
@@ -15,6 +15,8 @@ export interface Project {
root_dir: string; root_dir: string;
git_remote?: string | null; git_remote?: string | null;
credential_ref?: string | null; credential_ref?: string | null;
/* Owning user account; null = shared/legacy (visible to everyone, admin-managed). */
owner_user_id?: number | null;
created_at: string; created_at: string;
/* Present on the registration response in git-server mode: the enqueued clone. */ /* Present on the registration response in git-server mode: the enqueued clone. */
sync_command_id?: number | null; sync_command_id?: number | null;
@@ -156,6 +158,7 @@ export interface ClaudeSkill {
/* Relative paths of auxiliary files captured by an install-from-prompt import /* Relative paths of auxiliary files captured by an install-from-prompt import
* (references/, scripts/, …); synced alongside SKILL.md, read-only here. */ * (references/, scripts/, …); synced alongside SKILL.md, read-only here. */
files: string[]; files: string[];
owner_user_id?: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -172,6 +175,7 @@ export interface ClaudeConnector {
url?: string | null; url?: string | null;
headers?: Record<string, string> | null; headers?: Record<string, string> | null;
enabled: boolean; enabled: boolean;
owner_user_id?: number | null;
created_at: string; created_at: string;
} }
@@ -181,6 +185,7 @@ export interface ClaudePlugin {
marketplace: string; marketplace: string;
marketplace_repo: string; marketplace_repo: string;
enabled: boolean; enabled: boolean;
owner_user_id?: number | null;
created_at: string; created_at: string;
} }
@@ -199,6 +204,7 @@ export interface ClaudeModel {
env?: Record<string, string> | null; env?: Record<string, string> | null;
enabled: boolean; enabled: boolean;
has_api_key: boolean; has_api_key: boolean;
owner_user_id?: number | null;
created_at: string; created_at: string;
} }
@@ -250,6 +256,71 @@ export interface SharedContext {
updated_at: string; updated_at: string;
} }
/* ---- user accounts (/auth) ---- */
export interface AuthStatus {
initialized: boolean; // any account exists; false => show the first-run setup form
smtp_configured: boolean;
}
export interface User {
id: number;
email: string;
is_admin: boolean;
disabled: boolean;
/* False until an invited user sets their password through their invite link. */
has_password: boolean;
created_at: string;
}
export interface Me {
kind: "user" | "token";
user_id?: number | null;
email?: string | null;
is_admin: boolean;
}
export interface SessionResponse {
token: string;
user: User;
}
export interface UserCreated {
user: User;
invite_url: string;
emailed: boolean;
}
export interface ResetLink {
reset_url: string;
emailed: boolean;
}
/* Unauthenticated auth calls (status/login/setup/forgot/reset) — used by the gate
* before any token exists, so they sit outside createClient. */
export async function authApi<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let detail: string = res.statusText;
try {
const j = await res.json();
if (j && typeof j.detail !== "undefined") {
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
}
} catch {
/* non-JSON error body; keep statusText */
}
const err = new Error(detail) as ApiError;
err.status = res.status;
throw err;
}
return (await res.json()) as T;
}
/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay /* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
* quiet while the app re-prompts for a token. */ * quiet while the app re-prompts for a token. */
export class AuthError extends Error { export class AuthError extends Error {
+2
View File
@@ -20,6 +20,8 @@ export const NAV_ROUTES: NavRoute[] = [
{ key: "shared", href: "/shared", label: "Shared" }, { key: "shared", href: "/shared", label: "Shared" },
{ key: "memory", href: "/memory", label: "Memory" }, { key: "memory", href: "/memory", label: "Memory" },
{ key: "claude", href: "/claude", label: "Claude" }, { key: "claude", href: "/claude", label: "Claude" },
// Admin-only: the Shell hides this entry for non-admin sessions.
{ key: "users", href: "/users", label: "Users" },
]; ];
/* Map a browser path back to its section key. Trailing slashes (Next emits them under /* Map a browser path back to its section key. Trailing slashes (Next emits them under
+2
View File
@@ -17,6 +17,7 @@ from ..config import get_settings
from .routes import ( from .routes import (
agents, agents,
approvals, approvals,
auth,
claude, claude,
commands, commands,
hosts, hosts,
@@ -44,6 +45,7 @@ def create_app() -> FastAPI:
def health() -> dict: def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
app.include_router(auth.router)
app.include_router(projects.router) app.include_router(projects.router)
app.include_router(agents.router) app.include_router(agents.router)
app.include_router(interaction.router) app.include_router(interaction.router)
+120 -37
View File
@@ -1,24 +1,43 @@
"""Shared dependencies: bearer auth and a per-request DB connection. """Shared dependencies: bearer auth (user sessions + legacy env tokens) and a
per-request DB connection.
Auth is a single global token (README 3.3), compared in constant time. Shared-context Two kinds of callers hold a bearer token:
writes may require a separate higher-trust token (README 3.4), falling back to the
global token when unset. - **Users** — email + password accounts (``/auth``). Their bearer is an opaque session
token minted at login; the database stores only its hash. A user is either an admin
(sees and manages everything) or a regular account, which sees *shared* resources
(owner NULL) plus its own — the per-user separation of projects, skills, and tools.
- **Legacy env tokens** — ``AUTH_TOKEN`` / ``SHARED_CONTEXT_WRITE_TOKEN`` /
``ADMIN_TOKEN``, compared in constant time exactly as before user accounts existed.
They keep working for scripts/CI and as a break-glass credential, with their original
semantics: they see every resource, and the admin token passes the admin gates.
Every request resolves to one :class:`Actor`; route handlers consult it for ownership
decisions (``visible_scope`` / ``can_edit``).
""" """
from __future__ import annotations from __future__ import annotations
import secrets import secrets
from collections.abc import Iterator from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import Connection from sqlalchemy import Connection
from .. import authn
from ..config import Settings, get_settings from ..config import Settings, get_settings
from ..db import repository as repo
from ..db.engine import connection from ..db.engine import connection
_bearer = HTTPBearer(auto_error=False) _bearer = HTTPBearer(auto_error=False)
# How stale a session's last_used_at may get before we write a fresh one (the dashboard
# polls every few seconds; a write per poll would be pure churn).
_TOUCH_INTERVAL = timedelta(minutes=5)
def db_conn() -> Iterator[Connection]: def db_conn() -> Iterator[Connection]:
with connection() as conn: with connection() as conn:
@@ -31,52 +50,116 @@ def _check(provided: str | None, expected: str) -> bool:
return secrets.compare_digest(provided, expected) return secrets.compare_digest(provided, expected)
def require_auth( @dataclass(frozen=True)
class Actor:
"""Who is making this request: a signed-in user or a legacy env token."""
kind: str # "user" | "token"
user_id: int | None = None
email: str | None = None
is_admin: bool = False
shared_write: bool = False # may write shared_context keys
@property
def sees_all(self) -> bool:
"""Admins and legacy tokens see every resource (tokens keep their historical
all-access semantics for scripts); regular users see shared + their own."""
return self.is_admin or self.kind == "token"
@property
def visible_scope(self):
"""The ``visible_to`` argument for repository list functions."""
return repo.VISIBLE_ALL if self.sees_all else self.user_id
@property
def label(self) -> str:
"""The ``requested_by`` audit label for commands this actor enqueues."""
if self.kind == "user":
return f"user:{self.user_id}:{self.email}"
return "operator:web"
def can_edit(self, owner_user_id: int | None) -> bool:
"""Mutation rule for owned resources: admins (and the legacy admin token) edit
anything; a user edits what they own. Shared rows (owner NULL) are admin-managed."""
if self.is_admin:
return True
if self.kind == "user":
return owner_user_id is not None and owner_user_id == self.user_id
return False
def can_view(self, owner_user_id: int | None) -> bool:
if self.sees_all:
return True
return owner_user_id is None or owner_user_id == self.user_id
def get_actor(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer), creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
) -> None: conn: Connection = Depends(db_conn),
) -> Actor:
"""Resolve the request's bearer to an :class:`Actor` or raise 401."""
token = creds.credentials if creds else None token = creds.credentials if creds else None
# The shared-context write and admin tokens are higher-trust, so they also grant if token is None:
# normal access; a single request carries one bearer, and it should never be rejected raise _unauthorized()
# for being the more privileged one.
valid = ( # Legacy env tokens first (cheap constant-time compares). Each capability is
_check(token, settings.auth_token) # checked independently so the historical fallbacks hold exactly: with ADMIN_TOKEN
or _check(token, settings.effective_shared_write_token) # unset the plain token comes out admin, while a dedicated admin token does *not*
or _check(token, settings.effective_admin_token) # inherit shared-context write (that stays with the shared-write token, as before).
token_admin = _check(token, settings.effective_admin_token)
token_shared = _check(token, settings.effective_shared_write_token)
if token_admin or token_shared or _check(token, settings.auth_token):
return Actor(kind="token", is_admin=token_admin, shared_write=token_shared)
# Otherwise it may be a user session token (hash-stored).
token_hash = authn.hash_token(token)
row = repo.get_session_user(conn, token_hash)
if row is None:
raise _unauthorized()
last_used = row.get("session_last_used_at")
if last_used is None or datetime.now(UTC) - last_used > _TOUCH_INTERVAL:
repo.touch_auth_session(conn, token_hash)
return Actor(
kind="user",
user_id=row["id"],
email=row["email"],
is_admin=bool(row["is_admin"]),
shared_write=bool(row["is_admin"]),
) )
if not valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid or missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
def require_shared_write( def _unauthorized() -> HTTPException:
creds: HTTPAuthorizationCredentials | None = Depends(_bearer), return HTTPException(
settings: Settings = Depends(get_settings), status_code=status.HTTP_401_UNAUTHORIZED,
) -> None: detail="invalid or missing bearer token",
"""Gate for shared_context writes — the one table every project implicitly trusts.""" headers={"WWW-Authenticate": "Bearer"},
token = creds.credentials if creds else None )
if not _check(token, settings.effective_shared_write_token):
def require_auth(actor: Actor = Depends(get_actor)) -> Actor:
return actor
def require_shared_write(actor: Actor = Depends(get_actor)) -> Actor:
"""Gate for shared_context writes — the one table every project implicitly trusts.
Admin users, the admin token, and the dedicated shared-write token qualify."""
if not actor.shared_write:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="shared-context write requires the shared-context write token", detail="shared-context write requires the shared-context write token or an admin",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return actor
def require_admin( def require_admin(actor: Actor = Depends(get_actor)) -> Actor:
creds: HTTPAuthorizationCredentials | None = Depends(_bearer), """Gate for the global control surface: git servers, the Claude account login,
settings: Settings = Depends(get_settings), permission overrides, and user management. Admin users or the admin token."""
) -> None: if not actor.is_admin:
"""Gate for the web control surface: enqueuing control commands, project/host CRUD,
and credential-pointer edits. Requires specifically the admin token (which defaults to
the global token when ADMIN_TOKEN is unset)."""
token = creds.credentials if creds else None
if not _check(token, settings.effective_admin_token):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="this action requires the admin token", detail="this action requires an admin",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return actor
+52 -27
View File
@@ -13,7 +13,7 @@ from sqlalchemy import Connection
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_auth
from ..schemas import ( from ..schemas import (
AgentEventOut, AgentEventOut,
AgentIn, AgentIn,
@@ -23,7 +23,7 @@ from ..schemas import (
LogEntryOut, LogEntryOut,
SpawnIn, SpawnIn,
) )
from .common import resolve_agent from .common import resolve_agent, resolve_project
router = APIRouter( router = APIRouter(
prefix="/projects/{project}/agents", prefix="/projects/{project}/agents",
@@ -32,20 +32,24 @@ router = APIRouter(
) )
def _require_project(conn: Connection, project: str) -> None:
if repo.get_project(conn, project) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found")
@router.get("", response_model=list[AgentOut]) @router.get("", response_model=list[AgentOut])
def list_agents(project: str, conn: Connection = Depends(db_conn)) -> list[dict]: def list_agents(
_require_project(conn, project) project: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> list[dict]:
resolve_project(conn, project, actor)
return repo.list_agents(conn, project) return repo.list_agents(conn, project)
@router.post("", response_model=AgentOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=AgentOut, status_code=status.HTTP_201_CREATED)
def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn)) -> dict: def create_agent(
_require_project(conn, project) project: str,
body: AgentIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
resolve_project(conn, project, actor)
if repo.get_agent_by_name(conn, project, body.name) is not None: if repo.get_agent_by_name(conn, project, body.name) is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
@@ -68,11 +72,15 @@ def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn
"/spawn", "/spawn",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_conn)) -> dict: def enqueue_spawn(
project: str,
body: SpawnIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
"""Enqueue a spawn; the worker creates the agent row + claude process and reports back.""" """Enqueue a spawn; the worker creates the agent row + claude process and reports back."""
_require_project(conn, project) resolve_project(conn, project, actor, edit=True)
if repo.get_agent_by_name(conn, project, body.name) is not None: if repo.get_agent_by_name(conn, project, body.name) is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
@@ -89,8 +97,9 @@ def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_con
if body.model_id is not None: if body.model_id is not None:
# Same fail-fast idea for the model dropdown: the worker re-checks at launch, # Same fail-fast idea for the model dropdown: the worker re-checks at launch,
# but a stale/disabled selection should bounce now, not fail asynchronously. # but a stale/disabled selection should bounce now, not fail asynchronously.
# Ownership counts too: another user's private backend is "not found" here.
model = repo.get_claude_model(conn, body.model_id) model = repo.get_claude_model(conn, body.model_id)
if model is None: if model is None or not actor.can_view(model.get("owner_user_id")):
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=f"model {body.model_id} not found" status.HTTP_400_BAD_REQUEST, detail=f"model {body.model_id} not found"
) )
@@ -106,7 +115,7 @@ def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_con
project_id=project, project_id=project,
agent_name=body.name, agent_name=body.name,
payload=payload, payload=payload,
requested_by="operator:web", requested_by=actor.label,
) )
@@ -114,26 +123,40 @@ def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_con
"/{name}/kill", "/{name}/kill",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_kill(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: def enqueue_kill(
resolve_agent(conn, project, name) project: str,
name: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
resolve_agent(conn, project, name, actor, edit=True)
return repo.enqueue_command( return repo.enqueue_command(
conn, "kill", project_id=project, agent_name=name, requested_by="operator:web" conn, "kill", project_id=project, agent_name=name, requested_by=actor.label
) )
@router.delete("/{name}", dependencies=[Depends(require_admin)]) @router.delete("/{name}")
def delete_agent(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: def delete_agent(
project: str,
name: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
"""Remove the agent row (does not kill a live session — kill first).""" """Remove the agent row (does not kill a live session — kill first)."""
resolve_agent(conn, project, name) resolve_agent(conn, project, name, actor, edit=True)
repo.delete_agent(conn, project, name) repo.delete_agent(conn, project, name)
return {"deleted": name} return {"deleted": name}
@router.get("/{name}/checkmark", response_model=CheckmarkOut) @router.get("/{name}/checkmark", response_model=CheckmarkOut)
def get_checkmark(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: def get_checkmark(
agent = resolve_agent(conn, project, name) project: str,
name: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
agent = resolve_agent(conn, project, name, actor)
checkmark = repo.get_checkmark(conn, agent["id"]) checkmark = repo.get_checkmark(conn, agent["id"])
if checkmark is None: if checkmark is None:
raise HTTPException( raise HTTPException(
@@ -149,6 +172,7 @@ def get_events(
name: str, name: str,
after_id: int = Query(0, ge=0), after_id: int = Query(0, ge=0),
limit: int = Query(200, ge=1, le=1000), limit: int = Query(200, ge=1, le=1000),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
"""The headless run event stream, oldest-first, cursor-paged by row id. """The headless run event stream, oldest-first, cursor-paged by row id.
@@ -156,7 +180,7 @@ def get_events(
The UI polls with ``after_id`` = the largest id it has seen, so each poll returns The UI polls with ``after_id`` = the largest id it has seen, so each poll returns
only new events (an empty list for a legacy tmux agent or an idle one). only new events (an empty list for a legacy tmux agent or an idle one).
""" """
agent = resolve_agent(conn, project, name) agent = resolve_agent(conn, project, name, actor)
return repo.list_agent_events(conn, agent["id"], after_id=after_id, limit=limit) return repo.list_agent_events(conn, agent["id"], after_id=after_id, limit=limit)
@@ -166,7 +190,8 @@ def get_log(
name: str, name: str,
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
agent = resolve_agent(conn, project, name) agent = resolve_agent(conn, project, name, actor)
return repo.get_log(conn, agent["id"], limit=limit, offset=offset) return repo.get_log(conn, agent["id"], limit=limit, offset=offset)
+11 -12
View File
@@ -8,12 +8,13 @@ treats as a genuine second party (satisfying the "no self-approval" rule).
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_auth
from ..schemas import ApprovalIn, ApprovalOut, CommandOut from ..schemas import ApprovalIn, ApprovalOut, CommandOut
from .common import resolve_project
router = APIRouter( router = APIRouter(
prefix="/projects/{project}/approvals", prefix="/projects/{project}/approvals",
@@ -22,18 +23,14 @@ router = APIRouter(
) )
def _require_project(conn: Connection, project: str) -> None:
if repo.get_project(conn, project) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found")
@router.get("", response_model=list[ApprovalOut]) @router.get("", response_model=list[ApprovalOut])
def list_approvals( def list_approvals(
project: str, project: str,
branch: str | None = Query(None), branch: str | None = Query(None),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
_require_project(conn, project) resolve_project(conn, project, actor)
return repo.list_approvals(conn, project, branch=branch) return repo.list_approvals(conn, project, branch=branch)
@@ -41,12 +38,14 @@ def list_approvals(
"", "",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_approval( def enqueue_approval(
project: str, body: ApprovalIn, conn: Connection = Depends(db_conn) project: str,
body: ApprovalIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
_require_project(conn, project) resolve_project(conn, project, actor, edit=True)
payload = { payload = {
"branch": body.branch, "branch": body.branch,
"sha": body.sha, "sha": body.sha,
@@ -61,5 +60,5 @@ def enqueue_approval(
project_id=project, project_id=project,
agent_name=body.agent_name, agent_name=body.agent_name,
payload={k: v for k, v in payload.items() if v is not None}, payload={k: v for k, v in payload.items() if v is not None},
requested_by="operator:web", requested_by=actor.label,
) )
+341
View File
@@ -0,0 +1,341 @@
"""User accounts: sign-in, first-run setup, password resets, and admin user management.
The account model replaces "know the API key" for humans:
- **First run**: with zero accounts, ``POST /auth/setup`` creates the first one and it
is the admin. The UI probes ``GET /auth/status`` (unauthenticated, boolean-only) to
decide whether to show the setup form or the sign-in form.
- **Everyone after that** is created by an admin (``POST /auth/users``), which mints a
one-shot **invite link**; the invitee sets their own password through it. With SMTP
configured the link is emailed; either way it is returned to the admin.
- **Password reset**: self-serve ``POST /auth/forgot`` emails a short-lived reset link
(silent about whether the address exists); an admin can also mint a link directly
for any user. ``POST /auth/reset`` spends either kind of link.
Sessions are opaque bearer tokens (hash-stored, TTL from ``SESSION_TTL_DAYS``) used
exactly like the legacy env token — the client keeps calling with
``Authorization: Bearer …``. Legacy tokens stay valid for scripts/CI and break-glass.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import Connection
from ... import authn, emailer
from ...config import Settings, get_settings
from ...db import repository as repo
from ..deps import Actor, db_conn, get_actor, require_admin
from ..schemas import (
AuthStatusOut,
ChangePasswordIn,
ForgotIn,
ForgotOut,
LoginIn,
MeOut,
ResetIn,
ResetLinkOut,
SessionOut,
SetupIn,
UserCreatedOut,
UserCreateIn,
UserOut,
UserUpdateIn,
)
router = APIRouter(prefix="/auth", tags=["auth"])
# A throwaway hash so a login attempt against an unknown email costs the same scrypt
# work as one against a real account (no timing oracle on address existence).
_DUMMY_HASH = authn.hash_password("not-a-real-password")
def _user_out(row: dict) -> dict:
return {**row, "has_password": bool(row.get("password_hash"))}
def _new_session(conn: Connection, user: dict, settings: Settings) -> dict:
repo.purge_expired_sessions(conn) # piggybacked housekeeping — no scheduler needed
token = authn.new_token()
expires = datetime.now(UTC) + timedelta(days=settings.session_ttl_days)
repo.create_auth_session(conn, user["id"], authn.hash_token(token), expires)
return {"token": token, "user": _user_out(user)}
def _base_url(request: Request, settings: Settings) -> str:
base = settings.public_base_url.strip() or str(request.base_url)
return base.rstrip("/")
def _mint_link(
conn: Connection, request: Request, settings: Settings, user: dict, purpose: str
) -> str:
ttl_hours = (
settings.invite_token_ttl_hours if purpose == "invite" else settings.reset_token_ttl_hours
)
token = authn.new_token()
repo.create_auth_token(
conn,
user["id"],
authn.hash_token(token),
purpose,
datetime.now(UTC) + timedelta(hours=ttl_hours),
)
return f"{_base_url(request, settings)}/reset?token={token}"
def _try_email(user: dict, subject: str, body: str, settings: Settings) -> bool:
if not emailer.configured(settings):
return False
try:
emailer.send(user["email"], subject, body, settings)
return True
except emailer.EmailError:
# The link is still returned/usable; delivery failure must not lose it.
return False
# ---- public (unauthenticated) ----------------------------------------------------------
@router.get("/status", response_model=AuthStatusOut)
def auth_status(conn: Connection = Depends(db_conn)) -> dict:
return {
"initialized": repo.count_users(conn) > 0,
"smtp_configured": emailer.configured(get_settings()),
}
@router.post("/setup", response_model=SessionOut, status_code=status.HTTP_201_CREATED)
def setup(body: SetupIn, conn: Connection = Depends(db_conn)) -> dict:
"""Create the first account — the admin. Refused once any account exists."""
if repo.count_users(conn) > 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="already set up — sign in, or ask an admin to invite you",
)
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid email address")
user = repo.create_user(
conn, email, password_hash=authn.hash_password(body.password), is_admin=True
)
return _new_session(conn, user, get_settings())
@router.post("/login", response_model=SessionOut)
def login(body: LoginIn, conn: Connection = Depends(db_conn)) -> dict:
user = repo.get_user_by_email(conn, body.email)
stored = user["password_hash"] if user else _DUMMY_HASH
if not authn.verify_password(body.password, stored) or user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="invalid email or password")
if user["disabled"]:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="this account is disabled")
return _new_session(conn, user, get_settings())
@router.post("/forgot", response_model=ForgotOut)
def forgot(
body: ForgotIn, request: Request, conn: Connection = Depends(db_conn)
) -> dict:
"""Self-serve reset. Always answers ``ok`` — whether the address has an account is
not revealed. Without SMTP nothing can be sent; the UI tells the user to ask an
admin for a reset link instead."""
settings = get_settings()
if not emailer.configured(settings):
return {"ok": True, "emailed": False}
user = repo.get_user_by_email(conn, body.email)
if user is not None and not user["disabled"] and user.get("password_hash"):
link = _mint_link(conn, request, settings, user, "reset")
_try_email(
user,
"Reset your Handler password",
"A password reset was requested for this address.\n\n"
f"Reset it here (link expires in {settings.reset_token_ttl_hours}h):\n{link}\n\n"
"If you didn't request this, you can ignore this email.",
settings,
)
return {"ok": True, "emailed": True}
@router.post("/reset", response_model=SessionOut)
def reset(body: ResetIn, conn: Connection = Depends(db_conn)) -> dict:
"""Spend a reset/invite link: set the password and sign the user in. Every other
session for the account is revoked — a reset means the old credential is suspect."""
token_row = repo.consume_auth_token(conn, authn.hash_token(body.token))
if token_row is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="this link is invalid, expired, or already used — request a new one",
)
user = repo.get_user(conn, token_row["user_id"])
if user is None or user["disabled"]:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="this account is disabled")
user = repo.update_user(
conn, user["id"], password_hash=authn.hash_password(body.password)
)
repo.delete_user_sessions(conn, user["id"])
return _new_session(conn, user, get_settings())
# ---- authenticated self-service --------------------------------------------------------
@router.get("/me", response_model=MeOut)
def me(actor: Actor = Depends(get_actor)) -> dict:
return {
"kind": actor.kind,
"user_id": actor.user_id,
"email": actor.email,
"is_admin": actor.is_admin,
}
@router.post("/logout")
def logout(
request: Request,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
"""Revoke the presented session token. A no-op for legacy env tokens (they are
configuration, not sessions)."""
if actor.kind == "user":
auth_header = request.headers.get("authorization", "")
token = auth_header.split(" ", 1)[1] if " " in auth_header else ""
repo.delete_auth_session(conn, authn.hash_token(token))
return {"ok": True}
@router.post("/change-password")
def change_password(
body: ChangePasswordIn,
request: Request,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
if actor.kind != "user":
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="env tokens have no password to change"
)
user = repo.get_user(conn, actor.user_id)
if user is None or not authn.verify_password(body.current_password, user["password_hash"]):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="current password is incorrect")
repo.update_user(conn, user["id"], password_hash=authn.hash_password(body.new_password))
# Sign out every *other* session; the one making this change keeps working.
auth_header = request.headers.get("authorization", "")
token = auth_header.split(" ", 1)[1] if " " in auth_header else ""
repo.delete_user_sessions(conn, user["id"], keep_token_hash=authn.hash_token(token))
return {"ok": True}
# ---- admin user management -------------------------------------------------------------
def _target_or_404(conn: Connection, user_id: int) -> dict:
user = repo.get_user(conn, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"user {user_id} not found")
return user
def _guard_last_admin(conn: Connection, target: dict, detail: str) -> None:
"""Refuse a change that would leave zero sign-in-capable admins (lockout guard).
Only matters when the target currently counts as an active admin."""
if (
target["is_admin"]
and not target["disabled"]
and target.get("password_hash")
and repo.count_active_admins(conn, exclude_user_id=target["id"]) == 0
):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=detail)
@router.get("/users", response_model=list[UserOut], dependencies=[Depends(require_admin)])
def list_users(conn: Connection = Depends(db_conn)) -> list[dict]:
return [_user_out(u) for u in repo.list_users(conn)]
@router.post(
"/users",
response_model=UserCreatedOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_user(
body: UserCreateIn, request: Request, conn: Connection = Depends(db_conn)
) -> dict:
settings = get_settings()
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid email address")
if repo.get_user_by_email(conn, email) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"'{email}' already has an account")
user = repo.create_user(conn, email, password_hash=None, is_admin=body.is_admin)
link = _mint_link(conn, request, settings, user, "invite")
emailed = _try_email(
user,
"You've been invited to Handler",
"An admin created a Handler account for this address.\n\n"
f"Set your password here (link expires in {settings.invite_token_ttl_hours // 24} "
f"days):\n{link}\n",
settings,
)
return {"user": _user_out(user), "invite_url": link, "emailed": emailed}
@router.patch(
"/users/{user_id}", response_model=UserOut, dependencies=[Depends(require_admin)]
)
def update_user(
user_id: int, body: UserUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
target = _target_or_404(conn, user_id)
fields = body.model_dump(exclude_unset=True)
if fields.get("is_admin") is False or fields.get("disabled") is True:
_guard_last_admin(
conn, target, "refused: this is the last active admin — promote someone else first"
)
return _user_out(repo.update_user(conn, user_id, **fields))
@router.delete("/users/{user_id}", dependencies=[Depends(require_admin)])
def delete_user(
user_id: int,
actor: Actor = Depends(require_admin),
conn: Connection = Depends(db_conn),
) -> dict:
target = _target_or_404(conn, user_id)
if actor.kind == "user" and actor.user_id == user_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="you can't delete your own account"
)
_guard_last_admin(
conn, target, "refused: this is the last active admin — promote someone else first"
)
repo.delete_user(conn, user_id)
return {"deleted": target["email"], "note": "their projects/skills/tools became shared"}
@router.post(
"/users/{user_id}/reset-link",
response_model=ResetLinkOut,
dependencies=[Depends(require_admin)],
)
def mint_reset_link(
user_id: int, request: Request, conn: Connection = Depends(db_conn)
) -> dict:
"""Admin-minted reset link — the escape hatch when SMTP is off (or the email never
arrived). Uses invite semantics (longer TTL) for accounts that never set a password."""
settings = get_settings()
target = _target_or_404(conn, user_id)
purpose = "invite" if not target.get("password_hash") else "reset"
link = _mint_link(conn, request, settings, target, purpose)
emailed = _try_email(
target,
"Reset your Handler password",
f"An admin generated a password {purpose} link for your account:\n{link}\n",
settings,
)
return {"reset_url": link, "emailed": emailed}
+162 -63
View File
@@ -7,8 +7,11 @@ become the run's ``--mcp-config`` file, and plugins/permissions fold into the ge
per-agent ``settings.json`` (``control.settings_gen`` / ``control.claude_gen``). Changes per-agent ``settings.json`` (``control.settings_gen`` / ``control.claude_gen``). Changes
therefore apply to the *next* launch of every agent, not to runs already in flight. therefore apply to the *next* launch of every agent, not to runs already in flight.
Reads take the normal token; writes take the admin token (they shape what every agent Skills, connectors, plugins, and model backends are per-user resources: everyone sees
is allowed to do). The login flow stays under ``/login`` — it needs the worker's tmux. the **shared** rows (owner NULL, admin-managed) plus their own, users create and manage
their own rows, and only what's visible to a project's owner is applied to its agents'
launches. Permission overrides stay global and admin-gated. The login flow stays under
``/login`` — it needs the worker's tmux.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,7 +22,7 @@ from sqlalchemy import Connection
from ... import secretstore from ... import secretstore
from ...config import get_settings from ...config import get_settings
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_admin, require_auth
from ..schemas import ( from ..schemas import (
ClaudeConnectorIn, ClaudeConnectorIn,
ClaudeConnectorOut, ClaudeConnectorOut,
@@ -42,12 +45,32 @@ from ..schemas import (
router = APIRouter(prefix="/claude", tags=["claude"], dependencies=[Depends(require_auth)]) router = APIRouter(prefix="/claude", tags=["claude"], dependencies=[Depends(require_auth)])
def _require_create(actor: Actor) -> None:
"""Creating rows: users always may (they own what they create); legacy tokens keep
their historical rule — only the admin token writes here."""
if actor.kind == "token" and not actor.is_admin:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="this action requires an admin token"
)
def _require_edit(actor: Actor, row: dict, what: str) -> None:
"""Mutating a row: the owner or an admin. Shared rows (owner NULL) are admin-managed."""
if not actor.can_edit(row.get("owner_user_id")):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=f"this {what} is shared — only an admin can change it"
if row.get("owner_user_id") is None
else f"this {what} belongs to another user",
)
# ---- skills --------------------------------------------------------------------------- # ---- skills ---------------------------------------------------------------------------
def _skill_or_404(conn: Connection, skill_id: int) -> dict: def _skill_or_404(conn: Connection, skill_id: int, actor: Actor) -> dict:
skill = repo.get_claude_skill(conn, skill_id) skill = repo.get_claude_skill(conn, skill_id)
if skill is None: if skill is None or not actor.can_view(skill.get("owner_user_id")):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"skill {skill_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"skill {skill_id} not found")
return skill return skill
@@ -60,21 +83,35 @@ def _skill_out(conn: Connection, row: dict) -> dict:
@router.get("/skills", response_model=list[ClaudeSkillOut]) @router.get("/skills", response_model=list[ClaudeSkillOut])
def list_skills(conn: Connection = Depends(db_conn)) -> list[dict]: def list_skills(
return [_skill_out(conn, s) for s in repo.list_claude_skills(conn)] actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn)
) -> list[dict]:
return [
_skill_out(conn, s)
for s in repo.list_claude_skills(conn, visible_to=actor.visible_scope)
]
@router.post( @router.post(
"/skills", "/skills",
response_model=ClaudeSkillOut, response_model=ClaudeSkillOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_skill(body: ClaudeSkillIn, conn: Connection = Depends(db_conn)) -> dict: def create_skill(
body: ClaudeSkillIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
_require_create(actor)
if repo.get_claude_skill_by_name(conn, body.name) is not None: if repo.get_claude_skill_by_name(conn, body.name) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"skill '{body.name}' exists") raise HTTPException(status.HTTP_409_CONFLICT, detail=f"skill '{body.name}' exists")
return repo.create_claude_skill( return repo.create_claude_skill(
conn, body.name, body.content, description=body.description, enabled=body.enabled conn,
body.name,
body.content,
description=body.description,
enabled=body.enabled,
owner_user_id=actor.user_id,
) )
@@ -82,25 +119,34 @@ def create_skill(body: ClaudeSkillIn, conn: Connection = Depends(db_conn)) -> di
"/skills/install", "/skills/install",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_skill_install(body: SkillInstallIn, conn: Connection = Depends(db_conn)) -> dict: def enqueue_skill_install(
body: SkillInstallIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
"""Run a pasted marketplace install prompt on the worker (which has ``claude`` and """Run a pasted marketplace install prompt on the worker (which has ``claude`` and
network) and import what it fetches as managed skills. The UI polls the returned network) and import what it fetches as managed skills. The UI polls the returned
command like any other control action; its result carries the imported skill names command like any other control action; its result carries the imported skill names
and claude's report of the defaults it chose.""" and claude's report of the defaults it chose. Imported skills belong to the
requesting user (shared when requested with the admin token)."""
_require_create(actor)
return repo.enqueue_command( return repo.enqueue_command(
conn, "skill_install", payload={"prompt": body.prompt}, requested_by="operator:web" conn,
"skill_install",
payload={"prompt": body.prompt, "owner_user_id": actor.user_id},
requested_by=actor.label,
) )
@router.patch( @router.patch("/skills/{skill_id}", response_model=ClaudeSkillOut)
"/skills/{skill_id}", response_model=ClaudeSkillOut, dependencies=[Depends(require_admin)]
)
def update_skill( def update_skill(
skill_id: int, body: ClaudeSkillUpdateIn, conn: Connection = Depends(db_conn) skill_id: int,
body: ClaudeSkillUpdateIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
_skill_or_404(conn, skill_id) _require_edit(actor, _skill_or_404(conn, skill_id, actor), "skill")
fields = body.model_dump(exclude_unset=True) fields = body.model_dump(exclude_unset=True)
if "name" in fields: if "name" in fields:
clash = repo.get_claude_skill_by_name(conn, fields["name"]) clash = repo.get_claude_skill_by_name(conn, fields["name"])
@@ -111,9 +157,14 @@ def update_skill(
return _skill_out(conn, repo.update_claude_skill(conn, skill_id, **fields)) return _skill_out(conn, repo.update_claude_skill(conn, skill_id, **fields))
@router.delete("/skills/{skill_id}", dependencies=[Depends(require_admin)]) @router.delete("/skills/{skill_id}")
def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_skill(
skill = _skill_or_404(conn, skill_id) skill_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
skill = _skill_or_404(conn, skill_id, actor)
_require_edit(actor, skill, "skill")
repo.delete_claude_skill(conn, skill_id) repo.delete_claude_skill(conn, skill_id)
return {"deleted": skill["name"]} return {"deleted": skill["name"]}
@@ -121,9 +172,9 @@ def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict:
# ---- connectors (MCP servers) --------------------------------------------------------- # ---- connectors (MCP servers) ---------------------------------------------------------
def _connector_or_404(conn: Connection, connector_id: int) -> dict: def _connector_or_404(conn: Connection, connector_id: int, actor: Actor) -> dict:
connector = repo.get_claude_connector(conn, connector_id) connector = repo.get_claude_connector(conn, connector_id)
if connector is None: if connector is None or not actor.can_view(connector.get("owner_user_id")):
raise HTTPException( raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"connector {connector_id} not found" status.HTTP_404_NOT_FOUND, detail=f"connector {connector_id} not found"
) )
@@ -131,17 +182,23 @@ def _connector_or_404(conn: Connection, connector_id: int) -> dict:
@router.get("/connectors", response_model=list[ClaudeConnectorOut]) @router.get("/connectors", response_model=list[ClaudeConnectorOut])
def list_connectors(conn: Connection = Depends(db_conn)) -> list[dict]: def list_connectors(
return repo.list_claude_connectors(conn) actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn)
) -> list[dict]:
return repo.list_claude_connectors(conn, visible_to=actor.visible_scope)
@router.post( @router.post(
"/connectors", "/connectors",
response_model=ClaudeConnectorOut, response_model=ClaudeConnectorOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_connector(body: ClaudeConnectorIn, conn: Connection = Depends(db_conn)) -> dict: def create_connector(
body: ClaudeConnectorIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
_require_create(actor)
if repo.get_claude_connector_by_name(conn, body.name) is not None: if repo.get_claude_connector_by_name(conn, body.name) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"connector '{body.name}' exists") raise HTTPException(status.HTTP_409_CONFLICT, detail=f"connector '{body.name}' exists")
return repo.create_claude_connector( return repo.create_claude_connector(
@@ -154,18 +211,22 @@ def create_connector(body: ClaudeConnectorIn, conn: Connection = Depends(db_conn
url=body.url, url=body.url,
headers=body.headers, headers=body.headers,
enabled=body.enabled, enabled=body.enabled,
owner_user_id=actor.user_id,
) )
@router.patch( @router.patch(
"/connectors/{connector_id}", "/connectors/{connector_id}",
response_model=ClaudeConnectorOut, response_model=ClaudeConnectorOut,
dependencies=[Depends(require_admin)],
) )
def update_connector( def update_connector(
connector_id: int, body: ClaudeConnectorUpdateIn, conn: Connection = Depends(db_conn) connector_id: int,
body: ClaudeConnectorUpdateIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
current = _connector_or_404(conn, connector_id) current = _connector_or_404(conn, connector_id, actor)
_require_edit(actor, current, "connector")
fields = body.model_dump(exclude_unset=True) fields = body.model_dump(exclude_unset=True)
if "name" in fields: if "name" in fields:
clash = repo.get_claude_connector_by_name(conn, fields["name"]) clash = repo.get_claude_connector_by_name(conn, fields["name"])
@@ -189,9 +250,14 @@ def update_connector(
return repo.update_claude_connector(conn, connector_id, **fields) return repo.update_claude_connector(conn, connector_id, **fields)
@router.delete("/connectors/{connector_id}", dependencies=[Depends(require_admin)]) @router.delete("/connectors/{connector_id}")
def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_connector(
connector = _connector_or_404(conn, connector_id) connector_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
connector = _connector_or_404(conn, connector_id, actor)
_require_edit(actor, connector, "connector")
repo.delete_claude_connector(conn, connector_id) repo.delete_claude_connector(conn, connector_id)
return {"deleted": connector["name"]} return {"deleted": connector["name"]}
@@ -199,42 +265,55 @@ def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) ->
# ---- plugins -------------------------------------------------------------------------- # ---- plugins --------------------------------------------------------------------------
def _plugin_or_404(conn: Connection, plugin_id: int) -> dict: def _plugin_or_404(conn: Connection, plugin_id: int, actor: Actor) -> dict:
plugin = repo.get_claude_plugin(conn, plugin_id) plugin = repo.get_claude_plugin(conn, plugin_id)
if plugin is None: if plugin is None or not actor.can_view(plugin.get("owner_user_id")):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"plugin {plugin_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"plugin {plugin_id} not found")
return plugin return plugin
@router.get("/plugins", response_model=list[ClaudePluginOut]) @router.get("/plugins", response_model=list[ClaudePluginOut])
def list_plugins(conn: Connection = Depends(db_conn)) -> list[dict]: def list_plugins(
return repo.list_claude_plugins(conn) actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn)
) -> list[dict]:
return repo.list_claude_plugins(conn, visible_to=actor.visible_scope)
@router.post( @router.post(
"/plugins", "/plugins",
response_model=ClaudePluginOut, response_model=ClaudePluginOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_plugin(body: ClaudePluginIn, conn: Connection = Depends(db_conn)) -> dict: def create_plugin(
body: ClaudePluginIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
_require_create(actor)
if repo.get_claude_plugin_by_key(conn, body.name, body.marketplace) is not None: if repo.get_claude_plugin_by_key(conn, body.name, body.marketplace) is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
detail=f"plugin '{body.name}@{body.marketplace}' exists", detail=f"plugin '{body.name}@{body.marketplace}' exists",
) )
return repo.create_claude_plugin( return repo.create_claude_plugin(
conn, body.name, body.marketplace, body.marketplace_repo, enabled=body.enabled conn,
body.name,
body.marketplace,
body.marketplace_repo,
enabled=body.enabled,
owner_user_id=actor.user_id,
) )
@router.patch( @router.patch("/plugins/{plugin_id}", response_model=ClaudePluginOut)
"/plugins/{plugin_id}", response_model=ClaudePluginOut, dependencies=[Depends(require_admin)]
)
def update_plugin( def update_plugin(
plugin_id: int, body: ClaudePluginUpdateIn, conn: Connection = Depends(db_conn) plugin_id: int,
body: ClaudePluginUpdateIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
current = _plugin_or_404(conn, plugin_id) current = _plugin_or_404(conn, plugin_id, actor)
_require_edit(actor, current, "plugin")
fields = body.model_dump(exclude_unset=True) fields = body.model_dump(exclude_unset=True)
if "name" in fields or "marketplace" in fields: if "name" in fields or "marketplace" in fields:
merged = {**current, **fields} merged = {**current, **fields}
@@ -247,9 +326,14 @@ def update_plugin(
return repo.update_claude_plugin(conn, plugin_id, **fields) return repo.update_claude_plugin(conn, plugin_id, **fields)
@router.delete("/plugins/{plugin_id}", dependencies=[Depends(require_admin)]) @router.delete("/plugins/{plugin_id}")
def delete_plugin(plugin_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_plugin(
plugin = _plugin_or_404(conn, plugin_id) plugin_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
plugin = _plugin_or_404(conn, plugin_id, actor)
_require_edit(actor, plugin, "plugin")
repo.delete_claude_plugin(conn, plugin_id) repo.delete_claude_plugin(conn, plugin_id)
return {"deleted": f"{plugin['name']}@{plugin['marketplace']}"} return {"deleted": f"{plugin['name']}@{plugin['marketplace']}"}
@@ -261,9 +345,9 @@ def delete_plugin(plugin_id: int, conn: Connection = Depends(db_conn)) -> dict:
# key is encrypted at rest (HANDLER_SECRET_KEY) and never returned. # key is encrypted at rest (HANDLER_SECRET_KEY) and never returned.
def _model_or_404(conn: Connection, model_id: int) -> dict: def _model_or_404(conn: Connection, model_id: int, actor: Actor) -> dict:
row = repo.get_claude_model(conn, model_id) row = repo.get_claude_model(conn, model_id)
if row is None: if row is None or not actor.can_view(row.get("owner_user_id")):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"model {model_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"model {model_id} not found")
return row return row
@@ -282,17 +366,25 @@ def _encrypt_key_or_400(value: str) -> str:
@router.get("/models", response_model=list[ClaudeModelOut]) @router.get("/models", response_model=list[ClaudeModelOut])
def list_models(conn: Connection = Depends(db_conn)) -> list[dict]: def list_models(
return [_model_out(m) for m in repo.list_claude_models(conn)] actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn)
) -> list[dict]:
return [
_model_out(m) for m in repo.list_claude_models(conn, visible_to=actor.visible_scope)
]
@router.post( @router.post(
"/models", "/models",
response_model=ClaudeModelOut, response_model=ClaudeModelOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_model(body: ClaudeModelIn, conn: Connection = Depends(db_conn)) -> dict: def create_model(
body: ClaudeModelIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
_require_create(actor)
if repo.get_claude_model_by_name(conn, body.name) is not None: if repo.get_claude_model_by_name(conn, body.name) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"model '{body.name}' exists") raise HTTPException(status.HTTP_409_CONFLICT, detail=f"model '{body.name}' exists")
api_key_enc = _encrypt_key_or_400(body.api_key) if body.api_key else None api_key_enc = _encrypt_key_or_400(body.api_key) if body.api_key else None
@@ -307,17 +399,19 @@ def create_model(body: ClaudeModelIn, conn: Connection = Depends(db_conn)) -> di
harness=body.harness, harness=body.harness,
env=body.env, env=body.env,
enabled=body.enabled, enabled=body.enabled,
owner_user_id=actor.user_id,
) )
) )
@router.patch( @router.patch("/models/{model_id}", response_model=ClaudeModelOut)
"/models/{model_id}", response_model=ClaudeModelOut, dependencies=[Depends(require_admin)]
)
def update_model( def update_model(
model_id: int, body: ClaudeModelUpdateIn, conn: Connection = Depends(db_conn) model_id: int,
body: ClaudeModelUpdateIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
_model_or_404(conn, model_id) _require_edit(actor, _model_or_404(conn, model_id, actor), "model")
fields = body.model_dump(exclude_unset=True) fields = body.model_dump(exclude_unset=True)
if "name" in fields: if "name" in fields:
clash = repo.get_claude_model_by_name(conn, fields["name"]) clash = repo.get_claude_model_by_name(conn, fields["name"])
@@ -335,9 +429,14 @@ def update_model(
return _model_out(repo.update_claude_model(conn, model_id, **fields)) return _model_out(repo.update_claude_model(conn, model_id, **fields))
@router.delete("/models/{model_id}", dependencies=[Depends(require_admin)]) @router.delete("/models/{model_id}")
def delete_model(model_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_model(
row = _model_or_404(conn, model_id) model_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
row = _model_or_404(conn, model_id, actor)
_require_edit(actor, row, "model")
repo.delete_claude_model(conn, model_id) repo.delete_claude_model(conn, model_id)
return {"deleted": row["name"]} return {"deleted": row["name"]}
+26 -6
View File
@@ -12,8 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_admin, require_auth
from ..schemas import CommandOut from ..schemas import CommandOut
from .common import visible_project_ids
router = APIRouter(tags=["commands"], dependencies=[Depends(require_auth)]) router = APIRouter(tags=["commands"], dependencies=[Depends(require_auth)])
@@ -23,14 +24,32 @@ def list_commands(
project: str | None = Query(None), project: str | None = Query(None),
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
return repo.list_commands(conn, project_id=project, limit=limit, offset=offset) """The activity feed. Non-admin users see commands on projects they can see, plus
non-project commands they enqueued themselves (so they can track e.g. an install)."""
return repo.list_commands(
conn,
project_id=project,
limit=limit,
offset=offset,
restrict_to_projects=visible_project_ids(conn, actor),
or_requested_by=actor.label,
)
@router.get("/commands/{command_id}", response_model=CommandOut) @router.get("/commands/{command_id}", response_model=CommandOut)
def get_command(command_id: int, conn: Connection = Depends(db_conn)) -> dict: def get_command(
command_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
command = repo.get_command(conn, command_id) command = repo.get_command(conn, command_id)
if command is not None and not actor.sees_all:
visible = set(visible_project_ids(conn, actor) or [])
if command.get("project_id") not in visible and command.get("requested_by") != actor.label:
command = None
if command is None: if command is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"command {command_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"command {command_id} not found")
return command return command
@@ -40,8 +59,9 @@ def get_command(command_id: int, conn: Connection = Depends(db_conn)) -> dict:
"/poll-ci", "/poll-ci",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_global_poll_ci(conn: Connection = Depends(db_conn)) -> dict: def enqueue_global_poll_ci(
actor: Actor = Depends(require_admin), conn: Connection = Depends(db_conn)
) -> dict:
"""Enqueue a CI sweep across every project (per-project sweep is on the project route).""" """Enqueue a CI sweep across every project (per-project sweep is on the project route)."""
return repo.enqueue_command(conn, "poll_ci", requested_by="operator:web") return repo.enqueue_command(conn, "poll_ci", requested_by=actor.label)
+41 -8
View File
@@ -1,4 +1,12 @@
"""Small route helpers shared across agent-scoped endpoints.""" """Small route helpers shared across project/agent-scoped endpoints.
Ownership rules (user accounts): every project is either **owned** by one user or
**shared** (owner NULL legacy rows and anything an admin leaves communal). Admins and
legacy env tokens see everything; a regular user sees shared projects plus their own.
Mutations follow ``Actor.can_edit``: owners manage their projects, admins manage
everything, shared projects are admin-managed. Invisible resources 404 rather than 403,
so their existence is not leaked across the user boundary.
"""
from __future__ import annotations from __future__ import annotations
@@ -6,16 +14,41 @@ from fastapi import HTTPException, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo from ...db import repository as repo
from ..deps import Actor
def resolve_agent(conn: Connection, project: str, name: str) -> dict: def resolve_project(
"""Fetch an agent by ``(project, name)`` or 404. conn: Connection, project_id: str, actor: Actor, *, edit: bool = False
) -> dict:
"""Fetch a project the actor may see (404 otherwise); with ``edit=True`` also
require mutation rights (403). This is the project-isolation choke point every
nested route resolves through here, so nothing crosses a project or user boundary."""
project = repo.get_project(conn, project_id)
if project is None or not actor.can_view(project.get("owner_user_id")):
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"project '{project_id}' not found"
)
if edit and not actor.can_edit(project.get("owner_user_id")):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=f"project '{project_id}' is managed by its owner (or an admin)",
)
return project
Enforces project isolation (README 3.4): the lookup is always project-scoped, so
there is no path that returns another project's agent by accident. def visible_project_ids(conn: Connection, actor: Actor) -> list[str] | None:
""" """The project ids a non-admin user may see, or None for no restriction."""
if repo.get_project(conn, project) is None: if actor.sees_all:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found") return None
return [p["id"] for p in repo.list_projects(conn, visible_to=actor.visible_scope)]
def resolve_agent(
conn: Connection, project: str, name: str, actor: Actor, *, edit: bool = False
) -> dict:
"""Fetch an agent by ``(project, name)`` or 404, enforcing project visibility
(README 3.4) there is no path that returns another project's agent by accident."""
resolve_project(conn, project, actor, edit=edit)
agent = repo.get_agent_by_name(conn, project, name) agent = repo.get_agent_by_name(conn, project, name)
if agent is None: if agent is None:
raise HTTPException( raise HTTPException(
+6 -5
View File
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_auth
from ..schemas import AnswerIn, AnswerOut, CommandOut, ResumeIn from ..schemas import AnswerIn, AnswerOut, CommandOut, ResumeIn
from .common import resolve_agent from .common import resolve_agent
@@ -30,9 +30,10 @@ def answer(
project: str, project: str,
name: str, name: str,
body: AnswerIn, body: AnswerIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> AnswerOut: ) -> AnswerOut:
agent = resolve_agent(conn, project, name) agent = resolve_agent(conn, project, name, actor)
if body.log_entry_id is not None: if body.log_entry_id is not None:
log_entry_id = body.log_entry_id log_entry_id = body.log_entry_id
@@ -58,15 +59,15 @@ def answer(
"/resume", "/resume",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def resume( def resume(
project: str, project: str,
name: str, name: str,
body: ResumeIn, body: ResumeIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
agent = resolve_agent(conn, project, name) agent = resolve_agent(conn, project, name, actor, edit=True)
# Resolve the answer to feed back here (the API has the log); the worker just delivers. # Resolve the answer to feed back here (the API has the log); the worker just delivers.
answer_text = body.answer answer_text = body.answer
@@ -92,5 +93,5 @@ def resume(
project_id=project, project_id=project,
agent_name=name, agent_name=name,
payload={"answer": answer_text}, payload={"answer": answer_text},
requested_by="operator:web", requested_by=actor.label,
) )
+88 -32
View File
@@ -3,8 +3,10 @@
Agents write here through the bundled handler-memory MCP server (direct DB, like the Agents write here through the bundled handler-memory MCP server (direct DB, like the
hooks); these routes are the dashboard's window plus the operator's editing surface hooks); these routes are the dashboard's window plus the operator's editing surface
no worker round-trip, nothing touches a live process, same trust model as the Claude no worker round-trip, nothing touches a live process, same trust model as the Claude
management pages. Reads take the normal token; writes take the admin token (the notes management pages. Visibility follows the note's project (global notes are visible to
feed every future agent's context, so authoring them is a control-surface action). everyone); writing follows edit rights a project's owner (or an admin) authors its
notes, and **global** notes are admin-only, since they feed every future agent's
context across every user.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_auth
from ..schemas import ( from ..schemas import (
MemoryGraphOut, MemoryGraphOut,
MemoryLinkIn, MemoryLinkIn,
@@ -22,22 +24,37 @@ from ..schemas import (
MemoryNoteOut, MemoryNoteOut,
MemoryNoteUpdateIn, MemoryNoteUpdateIn,
) )
from .common import resolve_project, visible_project_ids
router = APIRouter(prefix="/memory", tags=["memory"], dependencies=[Depends(require_auth)]) router = APIRouter(prefix="/memory", tags=["memory"], dependencies=[Depends(require_auth)])
def _note_or_404(conn: Connection, note_id: int) -> dict: def _note_or_404(conn: Connection, note_id: int, actor: Actor) -> dict:
note = repo.get_memory_note(conn, note_id) note = repo.get_memory_note(conn, note_id)
if note is not None and note.get("project_id") is not None and not actor.sees_all:
project = repo.get_project(conn, note["project_id"])
if project is None or not actor.can_view(project.get("owner_user_id")):
note = None
if note is None: if note is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"note {note_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"note {note_id} not found")
return note return note
def _project_or_400(conn: Connection, project_id: str | None) -> None: def _require_note_edit(conn: Connection, note_project_id: str | None, actor: Actor) -> None:
if project_id is not None and repo.get_project(conn, project_id) is None: """Edit gate for a note's scope: project notes follow the project's owner; global
notes are admin-only (they reach every user's agents)."""
if note_project_id is None:
if not actor.is_admin:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="global notes are admin-managed"
)
return
project = repo.get_project(conn, note_project_id)
if project is None:
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=f"project '{project_id}' not found" status.HTTP_400_BAD_REQUEST, detail=f"project '{note_project_id}' not found"
) )
resolve_project(conn, note_project_id, actor, edit=True)
@router.get("/notes", response_model=list[MemoryNoteOut]) @router.get("/notes", response_model=list[MemoryNoteOut])
@@ -46,36 +63,52 @@ def list_notes(
q: str | None = Query(None), q: str | None = Query(None),
limit: int = Query(200, ge=1, le=1000), limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
"""Notes in scope, newest first; ``q`` switches to substring search (all terms).""" """Notes in scope, newest first; ``q`` switches to substring search (all terms)."""
visible = visible_project_ids(conn, actor)
if q: if q:
return repo.search_memory_notes(conn, q, project_id=project_id, limit=limit) return repo.search_memory_notes(
return repo.list_memory_notes(conn, project_id=project_id, limit=limit, offset=offset) conn, q, project_id=project_id, limit=limit, visible_project_ids=visible
)
return repo.list_memory_notes(
conn, project_id=project_id, limit=limit, offset=offset, visible_project_ids=visible
)
@router.get("/graph", response_model=MemoryGraphOut) @router.get("/graph", response_model=MemoryGraphOut)
def graph( def graph(
project_id: str | None = Query(None), project_id: str | None = Query(None),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
"""The whole web of notes in one read — what the Memory page draws.""" """The whole web of notes in one read — what the Memory page draws."""
return repo.memory_graph(conn, project_id=project_id) return repo.memory_graph(
conn, project_id=project_id, visible_project_ids=visible_project_ids(conn, actor)
)
@router.get("/notes/{note_id}", response_model=MemoryNoteOut) @router.get("/notes/{note_id}", response_model=MemoryNoteOut)
def get_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict: def get_note(
return _note_or_404(conn, note_id) note_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
return _note_or_404(conn, note_id, actor)
@router.post( @router.post(
"/notes", "/notes",
response_model=MemoryNoteOut, response_model=MemoryNoteOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_note(body: MemoryNoteIn, conn: Connection = Depends(db_conn)) -> dict: def create_note(
_project_or_400(conn, body.project_id) body: MemoryNoteIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
_require_note_edit(conn, body.project_id, actor)
return repo.create_memory_note( return repo.create_memory_note(
conn, conn,
title=body.title, title=body.title,
@@ -87,22 +120,30 @@ def create_note(body: MemoryNoteIn, conn: Connection = Depends(db_conn)) -> dict
) )
@router.patch( @router.patch("/notes/{note_id}", response_model=MemoryNoteOut)
"/notes/{note_id}", response_model=MemoryNoteOut, dependencies=[Depends(require_admin)]
)
def update_note( def update_note(
note_id: int, body: MemoryNoteUpdateIn, conn: Connection = Depends(db_conn) note_id: int,
body: MemoryNoteUpdateIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
_note_or_404(conn, note_id) note = _note_or_404(conn, note_id, actor)
_require_note_edit(conn, note.get("project_id"), actor)
fields = body.model_dump(exclude_unset=True) fields = body.model_dump(exclude_unset=True)
if "project_id" in fields: if "project_id" in fields and fields["project_id"] != note.get("project_id"):
_project_or_400(conn, fields["project_id"]) # Moving a note is an edit of both scopes (the old one loses it, the new gains it).
_require_note_edit(conn, fields["project_id"], actor)
return repo.update_memory_note(conn, note_id, **fields) return repo.update_memory_note(conn, note_id, **fields)
@router.delete("/notes/{note_id}", dependencies=[Depends(require_admin)]) @router.delete("/notes/{note_id}")
def delete_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_note(
note = _note_or_404(conn, note_id) note_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
note = _note_or_404(conn, note_id, actor)
_require_note_edit(conn, note.get("project_id"), actor)
repo.delete_memory_note(conn, note_id) repo.delete_memory_note(conn, note_id)
return {"deleted": note["id"]} return {"deleted": note["id"]}
@@ -111,20 +152,35 @@ def delete_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict:
"/links", "/links",
response_model=MemoryLinkOut, response_model=MemoryLinkOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_link(body: MemoryLinkIn, conn: Connection = Depends(db_conn)) -> dict: def create_link(
body: MemoryLinkIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
if body.src_note_id == body.dst_note_id: if body.src_note_id == body.dst_note_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="a note cannot link to itself") raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="a note cannot link to itself")
_note_or_404(conn, body.src_note_id) src = _note_or_404(conn, body.src_note_id, actor)
_note_or_404(conn, body.dst_note_id) dst = _note_or_404(conn, body.dst_note_id, actor)
_require_note_edit(conn, src.get("project_id"), actor)
_require_note_edit(conn, dst.get("project_id"), actor)
return repo.create_memory_link( return repo.create_memory_link(
conn, body.src_note_id, body.dst_note_id, relation=body.relation, agent_id=None conn, body.src_note_id, body.dst_note_id, relation=body.relation, agent_id=None
) )
@router.delete("/links/{link_id}", dependencies=[Depends(require_admin)]) @router.delete("/links/{link_id}")
def delete_link(link_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_link(
if not repo.delete_memory_link(conn, link_id): link_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
link = repo.get_memory_link(conn, link_id)
if link is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"link {link_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"link {link_id} not found")
src = _note_or_404(conn, link["src_note_id"], actor)
dst = _note_or_404(conn, link["dst_note_id"], actor)
_require_note_edit(conn, src.get("project_id"), actor)
_require_note_edit(conn, dst.get("project_id"), actor)
repo.delete_memory_link(conn, link_id)
return {"deleted": link_id} return {"deleted": link_id}
+63 -36
View File
@@ -1,8 +1,9 @@
"""Project CRUD + project-scoped control actions. """Project CRUD + project-scoped control actions.
Reads and row registration take the normal token; edits/deletes and the enqueue actions Every route resolves through the ownership rules in ``routes.common``: users see shared
(forge-init, poll-ci) take the admin token. The agent *process* work (spawn/kill) lives in projects plus their own, admins (and legacy tokens) see everything, and mutations plus
``agents.py``; here we cover the project itself and the two project-wide control actions. the enqueue actions (sync, forge-init, poll-ci) require the owner or an admin. New
projects belong to the creating user (shared when registered with an env token).
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,27 +17,27 @@ from sqlalchemy.exc import IntegrityError
from ...config import get_settings from ...config import get_settings
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_auth
from ..schemas import CommandOut, ProjectCreatedOut, ProjectIn, ProjectOut, ProjectUpdateIn from ..schemas import CommandOut, ProjectCreatedOut, ProjectIn, ProjectOut, ProjectUpdateIn
from .common import resolve_project
router = APIRouter(prefix="/projects", tags=["projects"], dependencies=[Depends(require_auth)]) router = APIRouter(prefix="/projects", tags=["projects"], dependencies=[Depends(require_auth)])
def _get_or_404(conn: Connection, project_id: str) -> dict:
project = repo.get_project(conn, project_id)
if project is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project_id}' not found")
return project
@router.get("", response_model=list[ProjectOut]) @router.get("", response_model=list[ProjectOut])
def list_projects(conn: Connection = Depends(db_conn)) -> list[dict]: def list_projects(
return repo.list_projects(conn) actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn)
) -> list[dict]:
return repo.list_projects(conn, visible_to=actor.visible_scope)
@router.get("/{project_id}", response_model=ProjectOut) @router.get("/{project_id}", response_model=ProjectOut)
def get_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: def get_project(
return _get_or_404(conn, project_id) project_id: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
return resolve_project(conn, project_id, actor)
def _slug(value: str) -> str: def _slug(value: str) -> str:
@@ -73,7 +74,11 @@ def _from_git_server(body: ProjectIn, conn: Connection) -> tuple[str, str, str]:
@router.post("", response_model=ProjectCreatedOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ProjectCreatedOut, status_code=status.HTTP_201_CREATED)
def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict: def create_project(
body: ProjectIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
if body.git_server: if body.git_server:
project_id, root_dir, git_remote = _from_git_server(body, conn) project_id, root_dir, git_remote = _from_git_server(body, conn)
else: else:
@@ -88,6 +93,8 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict
root_dir=root_dir, root_dir=root_dir,
git_remote=git_remote, git_remote=git_remote,
credential_ref=body.credential_ref, credential_ref=body.credential_ref,
# The creating user owns their project; env tokens register shared ones.
owner_user_id=actor.user_id,
) )
except IntegrityError as exc: # pragma: no cover - guarded above except IntegrityError as exc: # pragma: no cover - guarded above
raise HTTPException(status.HTTP_409_CONFLICT, detail="project exists") from exc raise HTTPException(status.HTTP_409_CONFLICT, detail="project exists") from exc
@@ -97,7 +104,7 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict
sync_command_id = None sync_command_id = None
if git_remote: if git_remote:
command = repo.enqueue_command( command = repo.enqueue_command(
conn, "sync", project_id=project_id, requested_by="operator:web" conn, "sync", project_id=project_id, requested_by=actor.label
) )
sync_command_id = command["id"] sync_command_id = command["id"]
@@ -107,7 +114,7 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict
mise_init_command_id = None mise_init_command_id = None
if body.init_mise and git_remote: if body.init_mise and git_remote:
mise_command = repo.enqueue_command( mise_command = repo.enqueue_command(
conn, "mise_init", project_id=project_id, requested_by="operator:web" conn, "mise_init", project_id=project_id, requested_by=actor.label
) )
mise_init_command_id = mise_command["id"] mise_init_command_id = mise_command["id"]
@@ -118,18 +125,30 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict
} }
@router.patch("/{project_id}", response_model=ProjectOut, dependencies=[Depends(require_admin)]) @router.patch("/{project_id}", response_model=ProjectOut)
def update_project( def update_project(
project_id: str, body: ProjectUpdateIn, conn: Connection = Depends(db_conn) project_id: str,
body: ProjectUpdateIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
_get_or_404(conn, project_id) resolve_project(conn, project_id, actor, edit=True)
fields = body.model_dump(exclude_unset=True) fields = body.model_dump(exclude_unset=True)
# Reassigning ownership (including back to shared) is an admin-only move.
if "owner_user_id" in fields and not actor.is_admin:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="only an admin can reassign a project's owner"
)
return repo.update_project(conn, project_id, **fields) return repo.update_project(conn, project_id, **fields)
@router.delete("/{project_id}", dependencies=[Depends(require_admin)]) @router.delete("/{project_id}")
def delete_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: def delete_project(
_get_or_404(conn, project_id) project_id: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
resolve_project(conn, project_id, actor, edit=True)
repo.delete_project(conn, project_id) repo.delete_project(conn, project_id)
return {"deleted": project_id} return {"deleted": project_id}
@@ -138,18 +157,20 @@ def delete_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict
"/{project_id}/forge-init", "/{project_id}/forge-init",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_forge_init( def enqueue_forge_init(
project_id: str, no_commit: bool = False, conn: Connection = Depends(db_conn) project_id: str,
no_commit: bool = False,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
_get_or_404(conn, project_id) resolve_project(conn, project_id, actor, edit=True)
return repo.enqueue_command( return repo.enqueue_command(
conn, conn,
"forge_init", "forge_init",
project_id=project_id, project_id=project_id,
payload={"no_commit": no_commit}, payload={"no_commit": no_commit},
requested_by="operator:web", requested_by=actor.label,
) )
@@ -157,18 +178,21 @@ def enqueue_forge_init(
"/{project_id}/sync", "/{project_id}/sync",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_sync(project_id: str, conn: Connection = Depends(db_conn)) -> dict: def enqueue_sync(
project_id: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
"""Clone-or-pull the project's repo now (the worker executes it).""" """Clone-or-pull the project's repo now (the worker executes it)."""
project = _get_or_404(conn, project_id) project = resolve_project(conn, project_id, actor, edit=True)
if not project.get("git_remote"): if not project.get("git_remote"):
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,
detail=f"project '{project_id}' has no git_remote to sync from", detail=f"project '{project_id}' has no git_remote to sync from",
) )
return repo.enqueue_command( return repo.enqueue_command(
conn, "sync", project_id=project_id, requested_by="operator:web" conn, "sync", project_id=project_id, requested_by=actor.label
) )
@@ -176,10 +200,13 @@ def enqueue_sync(project_id: str, conn: Connection = Depends(db_conn)) -> dict:
"/{project_id}/poll-ci", "/{project_id}/poll-ci",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_poll_ci(project_id: str, conn: Connection = Depends(db_conn)) -> dict: def enqueue_poll_ci(
_get_or_404(conn, project_id) project_id: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
resolve_project(conn, project_id, actor, edit=True)
return repo.enqueue_command( return repo.enqueue_command(
conn, "poll_ci", project_id=project_id, requested_by="operator:web" conn, "poll_ci", project_id=project_id, requested_by=actor.label
) )
+44 -23
View File
@@ -6,8 +6,8 @@ so each run is a fresh, stateless agent. The canonical use: a standing prompt li
"Read @notes.md, continue from there, and overwrite that file before finishing", where "Read @notes.md, continue from there, and overwrite that file before finishing", where
the file in the repo carries the state between runs. the file in the repo carries the state between runs.
Reads take the normal token; writes take the admin token (a schedule ultimately runs Schedules belong to their project, so visibility and edit rights follow the project's
``claude`` in the control container). owner (a schedule ultimately runs ``claude`` against that project).
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,8 +18,9 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth from ..deps import Actor, db_conn, get_actor, require_auth
from ..schemas import ScheduleIn, ScheduleOut, ScheduleUpdateIn from ..schemas import ScheduleIn, ScheduleOut, ScheduleUpdateIn
from .common import resolve_project, visible_project_ids
router = APIRouter(tags=["schedules"], dependencies=[Depends(require_auth)]) router = APIRouter(tags=["schedules"], dependencies=[Depends(require_auth)])
@@ -33,13 +34,14 @@ def _schedule_or_404(conn: Connection, schedule_id: int) -> dict:
return schedule return schedule
def _check_model(conn: Connection, model_id: int | None) -> None: def _check_model(conn: Connection, model_id: int | None, actor: Actor) -> None:
"""Fail-fast for the model dropdown, mirroring the spawn route: a stale or disabled """Fail-fast for the model dropdown, mirroring the spawn route: a stale or disabled
selection bounces now instead of every firing failing asynchronously in Activity.""" selection bounces now instead of every firing failing asynchronously in Activity.
Ownership counts too: another user's private backend is "not found" here."""
if model_id is None: if model_id is None:
return return
model = repo.get_claude_model(conn, model_id) model = repo.get_claude_model(conn, model_id)
if model is None: if model is None or not actor.can_view(model.get("owner_user_id")):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=f"model {model_id} not found") raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=f"model {model_id} not found")
if not model["enabled"]: if not model["enabled"]:
raise HTTPException( raise HTTPException(
@@ -48,12 +50,24 @@ def _check_model(conn: Connection, model_id: int | None) -> None:
@router.get("/schedules", response_model=list[ScheduleOut]) @router.get("/schedules", response_model=list[ScheduleOut])
def list_all_schedules(conn: Connection = Depends(db_conn)) -> list[dict]: def list_all_schedules(
return repo.list_schedules(conn) actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn)
) -> list[dict]:
rows = repo.list_schedules(conn)
visible = visible_project_ids(conn, actor)
if visible is None:
return rows
allowed = set(visible)
return [r for r in rows if r["project_id"] in allowed]
@router.get("/projects/{project_id}/schedules", response_model=list[ScheduleOut]) @router.get("/projects/{project_id}/schedules", response_model=list[ScheduleOut])
def list_project_schedules(project_id: str, conn: Connection = Depends(db_conn)) -> list[dict]: def list_project_schedules(
project_id: str,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> list[dict]:
resolve_project(conn, project_id, actor)
return repo.list_schedules(conn, project_id) return repo.list_schedules(conn, project_id)
@@ -61,16 +75,15 @@ def list_project_schedules(project_id: str, conn: Connection = Depends(db_conn))
"/projects/{project_id}/schedules", "/projects/{project_id}/schedules",
response_model=ScheduleOut, response_model=ScheduleOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_schedule( def create_schedule(
project_id: str, body: ScheduleIn, conn: Connection = Depends(db_conn) project_id: str,
body: ScheduleIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
if repo.get_project(conn, project_id) is None: resolve_project(conn, project_id, actor, edit=True)
raise HTTPException( _check_model(conn, body.model_id, actor)
status.HTTP_404_NOT_FOUND, detail=f"project '{project_id}' not found"
)
_check_model(conn, body.model_id)
# next_run_at starts at now, so the first run fires on the worker's next pass — the # next_run_at starts at now, so the first run fires on the worker's next pass — the
# operator sees the schedule work immediately instead of waiting a full interval. # operator sees the schedule work immediately instead of waiting a full interval.
return repo.create_schedule( return repo.create_schedule(
@@ -91,20 +104,28 @@ def create_schedule(
@router.patch( @router.patch(
"/schedules/{schedule_id}", "/schedules/{schedule_id}",
response_model=ScheduleOut, response_model=ScheduleOut,
dependencies=[Depends(require_admin)],
) )
def update_schedule( def update_schedule(
schedule_id: int, body: ScheduleUpdateIn, conn: Connection = Depends(db_conn) schedule_id: int,
body: ScheduleUpdateIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
_schedule_or_404(conn, schedule_id) schedule = _schedule_or_404(conn, schedule_id)
resolve_project(conn, schedule["project_id"], actor, edit=True)
fields = body.model_dump(exclude_unset=True) fields = body.model_dump(exclude_unset=True)
if fields.get("model_id") is not None: if fields.get("model_id") is not None:
_check_model(conn, fields["model_id"]) _check_model(conn, fields["model_id"], actor)
return repo.update_schedule(conn, schedule_id, **fields) return repo.update_schedule(conn, schedule_id, **fields)
@router.delete("/schedules/{schedule_id}", dependencies=[Depends(require_admin)]) @router.delete("/schedules/{schedule_id}")
def delete_schedule(schedule_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_schedule(
_schedule_or_404(conn, schedule_id) schedule_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
schedule = _schedule_or_404(conn, schedule_id)
resolve_project(conn, schedule["project_id"], actor, edit=True)
repo.delete_schedule(conn, schedule_id) repo.delete_schedule(conn, schedule_id)
return {"deleted": schedule_id} return {"deleted": schedule_id}
+112
View File
@@ -90,6 +90,8 @@ class ProjectUpdateIn(BaseModel):
root_dir: str | None = None root_dir: str | None = None
git_remote: str | None = None git_remote: str | None = None
credential_ref: str | None = None credential_ref: str | None = None
# Reassign ownership (admin only); explicit null makes the project shared.
owner_user_id: int | None = None
@field_validator("credential_ref") @field_validator("credential_ref")
@classmethod @classmethod
@@ -104,6 +106,8 @@ class ProjectOut(BaseModel):
root_dir: str root_dir: str
git_remote: str | None = None git_remote: str | None = None
credential_ref: str | None = None credential_ref: str | None = None
# Owning user account; null = shared/legacy (visible to everyone, admin-managed).
owner_user_id: int | None = None
created_at: datetime created_at: datetime
@@ -409,6 +413,7 @@ class ClaudeSkillOut(BaseModel):
# Relative paths of auxiliary files (references/, scripts/, …) captured by the # Relative paths of auxiliary files (references/, scripts/, …) captured by the
# install-from-prompt import; synced alongside SKILL.md, read-only over the API. # install-from-prompt import; synced alongside SKILL.md, read-only over the API.
files: list[str] = Field(default_factory=list) files: list[str] = Field(default_factory=list)
owner_user_id: int | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -472,6 +477,7 @@ class ClaudeConnectorOut(BaseModel):
url: str | None = None url: str | None = None
headers: dict[str, str] | None = None headers: dict[str, str] | None = None
enabled: bool enabled: bool
owner_user_id: int | None = None
created_at: datetime created_at: datetime
@@ -521,6 +527,7 @@ class ClaudePluginOut(BaseModel):
marketplace: str marketplace: str
marketplace_repo: str marketplace_repo: str
enabled: bool enabled: bool
owner_user_id: int | None = None
created_at: datetime created_at: datetime
@@ -587,6 +594,7 @@ class ClaudeModelOut(BaseModel):
enabled: bool enabled: bool
# The key never leaves the server; this says whether one is stored. # The key never leaves the server; this says whether one is stored.
has_api_key: bool = False has_api_key: bool = False
owner_user_id: int | None = None
created_at: datetime created_at: datetime
@@ -680,3 +688,107 @@ class MemoryGraphOut(BaseModel):
notes: list[MemoryNoteOut] notes: list[MemoryNoteOut]
links: list[MemoryLinkOut] links: list[MemoryLinkOut]
# ---- user accounts & sessions (``/auth``) ----------------------------------------------
class AuthStatusOut(BaseModel):
"""Unauthenticated bootstrap probe: does the login page show a sign-in form or the
first-run setup form, and can the deployment send email?"""
initialized: bool # any user account exists
smtp_configured: bool
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
is_admin: bool
disabled: bool
# False until an invited user sets their password through the invite link.
has_password: bool = False
created_at: datetime
class SetupIn(BaseModel):
"""First-run: create the very first account, which becomes the admin."""
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=8, max_length=1024)
class LoginIn(BaseModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=1024)
class SessionOut(BaseModel):
"""A fresh bearer session token plus who it belongs to."""
token: str
user: UserOut
class MeOut(BaseModel):
"""Who the presented bearer resolves to. Legacy env tokens have no user identity —
``kind == "token"`` with null user fields."""
kind: str # "user" | "token"
user_id: int | None = None
email: str | None = None
is_admin: bool
class ChangePasswordIn(BaseModel):
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=8, max_length=1024)
class ForgotIn(BaseModel):
email: str = Field(min_length=3, max_length=254)
class ForgotOut(BaseModel):
"""Always ``ok`` — whether the address has an account is deliberately not revealed."""
ok: bool = True
emailed: bool # False when SMTP is not configured (ask an admin for a reset link)
class ResetIn(BaseModel):
"""Complete a reset or invite link: spend the one-shot token, set the password."""
token: str = Field(min_length=1, max_length=256)
password: str = Field(min_length=8, max_length=1024)
class UserCreateIn(BaseModel):
"""Admin creates an account; the new user sets their password via the invite link."""
email: str = Field(min_length=3, max_length=254)
is_admin: bool = False
class UserCreatedOut(BaseModel):
user: UserOut
# The invite link is always returned (the admin can hand it over out-of-band);
# ``emailed`` says whether it was also delivered by SMTP.
invite_url: str
emailed: bool
class UserUpdateIn(BaseModel):
"""Admin edits; omit a field to leave it unchanged."""
is_admin: bool | None = None
disabled: bool | None = None
class ResetLinkOut(BaseModel):
"""An admin-minted reset (or invite) link for a user."""
reset_url: str
emailed: bool
+73
View File
@@ -0,0 +1,73 @@
"""Password hashing and opaque-token helpers for user accounts.
Everything here is stdlib on purpose (``hashlib.scrypt`` + ``secrets``): no new
dependency for a code path every deployment runs. Passwords are stored as a
self-describing string carrying the scrypt parameters, so they can be raised later
without invalidating existing hashes. Session and reset tokens are random URL-safe
strings handed to the client; the database only ever stores their SHA-256, so a dump
never contains a usable credential.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
# Interactive-login scrypt parameters (~16 MB memory, fast enough for a login form,
# expensive enough to make offline cracking of a leaked hash unattractive).
_SCRYPT_N = 2**14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SALT_BYTES = 16
_KEY_BYTES = 32
MIN_PASSWORD_LENGTH = 8
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def hash_password(password: str) -> str:
"""``scrypt$N$r$p$salt$key`` for storage in ``users.password_hash``."""
salt = secrets.token_bytes(_SALT_BYTES)
key = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
dklen=_KEY_BYTES,
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${_b64(salt)}${_b64(key)}"
def verify_password(password: str, stored: str | None) -> bool:
"""Constant-time verification; False for malformed/absent hashes (an invited user
who never set a password can't log in with anything)."""
if not stored:
return False
try:
scheme, n, r, p, salt, key = stored.split("$")
if scheme != "scrypt":
return False
expected = _unb64(key)
computed = hashlib.scrypt(
password.encode(), salt=_unb64(salt), n=int(n), r=int(r), p=int(p),
dklen=len(expected),
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(computed, expected)
def new_token() -> str:
"""An opaque bearer credential (session / reset / invite) for the client to hold."""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""What the database stores in place of the token itself."""
return hashlib.sha256(token.encode()).hexdigest()
+25 -1
View File
@@ -19,7 +19,10 @@ class Settings(BaseSettings):
# "is it sqlite" except db.upsert. # "is it sqlite" except db.upsert.
database_url: str = "sqlite:///./handler.db" database_url: str = "sqlite:///./handler.db"
# The single global bearer token gating every API route (README 3.3). # Legacy/machine bearer token gating every API route (README 3.3). Human operators
# now sign in with email + password (user accounts, ``/auth``); this token remains
# for scripts/CI and as a break-glass credential, and may be left unset once
# accounts exist.
auth_token: str = "" auth_token: str = ""
# Optional higher-trust token for PUT /shared/context/:key. Falls back to # Optional higher-trust token for PUT /shared/context/:key. Falls back to
@@ -32,6 +35,27 @@ class Settings(BaseSettings):
# token, like auth_token — per-user RBAC is future work. # token, like auth_token — per-user RBAC is future work.
admin_token: str | None = None admin_token: str | None = None
# ---- User accounts (email + password sign-in for the dashboard/API).
# Browser session lifetime, and the validity windows for the one-shot links: a
# password reset is short-lived; an invite (set your first password) gets a week.
session_ttl_days: int = 30
reset_token_ttl_hours: int = 2
invite_token_ttl_hours: int = 168
# ---- Outbound email (invites + password resets). Unset SMTP_HOST => email off:
# admin flows return the invite/reset link in the response instead of mailing it.
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_from: str = ""
smtp_starttls: bool = True # STARTTLS on a plain connection (the common 587 setup)
smtp_ssl: bool = False # implicit TLS from byte one (the 465 setup)
# Base URL the emailed links point at, e.g. "https://handler.example.com". Falls
# back to the request's own origin when unset (right for the same-origin UI).
public_base_url: str = ""
# Optional generic webhook target for the Notification hook. No-op when unset. # Optional generic webhook target for the Notification hook. No-op when unset.
webhook_url: str | None = None webhook_url: str | None = None
+17 -8
View File
@@ -137,8 +137,8 @@ def sync_user_skills(skills: list[dict], home: str | None = None) -> list[str]:
return written return written
def _load_skills(conn: Connection) -> list[dict]: def _load_skills(conn: Connection, visible_to) -> list[dict]:
skills = repo.list_claude_skills(conn, enabled_only=True) skills = repo.list_claude_skills(conn, enabled_only=True, visible_to=visible_to)
return [ return [
{ {
**s, **s,
@@ -150,15 +150,24 @@ def _load_skills(conn: Connection) -> list[dict]:
] ]
def apply(working_dir: str, conn: Connection | None = None) -> dict: def apply(working_dir: str, conn: Connection | None = None, visible_to=None) -> dict:
"""Apply the whole web-managed config for one launch; returns a small summary.""" """Apply the whole web-managed config for one launch; returns a small summary.
``visible_to`` is the launching project's ``owner_user_id`` — the launch gets the
shared rows plus that user's own, so one user's skills and connectors never reach
another user's agents. ``None`` (a shared/legacy project) applies shared rows only,
which is exactly the pre-accounts behavior when nothing has an owner. Note the
skills sync target is the worker's user-level skills dir: each launch rewrites it
to its own visible set, so on a busy multi-user worker the set follows the most
recent launch (a bounded staleness, not a leak a launch never *reads* another
user's skills into its own sync)."""
if conn is None: if conn is None:
with connection() as c: with connection() as c:
connectors = repo.list_claude_connectors(c, enabled_only=True) connectors = repo.list_claude_connectors(c, enabled_only=True, visible_to=visible_to)
skills = _load_skills(c) skills = _load_skills(c, visible_to)
else: else:
connectors = repo.list_claude_connectors(conn, enabled_only=True) connectors = repo.list_claude_connectors(conn, enabled_only=True, visible_to=visible_to)
skills = _load_skills(conn) skills = _load_skills(conn, visible_to)
mcp_path = write_mcp_config(working_dir, [memory_server_connector()] + connectors) mcp_path = write_mcp_config(working_dir, [memory_server_connector()] + connectors)
written = sync_user_skills(skills) written = sync_user_skills(skills)
return {"mcp_config": mcp_path, "skills_written": len(written)} return {"mcp_config": mcp_path, "skills_written": len(written)}
+12 -6
View File
@@ -170,10 +170,13 @@ def _collect_skill(skill_dir: str) -> tuple[dict[str, str], dict[str, str], list
return files_and_meta return files_and_meta
def import_staged(staging_dir: str, conn: Connection) -> list[dict]: def import_staged(
staging_dir: str, conn: Connection, owner_user_id: int | None = None
) -> list[dict]:
"""Upsert every ``<staging>/<name>/SKILL.md`` as a managed skill row (matched by """Upsert every ``<staging>/<name>/SKILL.md`` as a managed skill row (matched by
name reinstalling a skill updates it in place) with its auxiliary files. Returns name reinstalling a skill updates it in place) with its auxiliary files. Returns
one summary dict per skill.""" one summary dict per skill. New rows belong to ``owner_user_id`` (None = shared);
a reinstall keeps the existing row's owner."""
results: list[dict] = [] results: list[dict] = []
for entry in sorted(os.listdir(staging_dir)): for entry in sorted(os.listdir(staging_dir)):
skill_dir = os.path.join(staging_dir, entry) skill_dir = os.path.join(staging_dir, entry)
@@ -190,7 +193,9 @@ def import_staged(staging_dir: str, conn: Connection) -> list[dict]:
body = meta["__body__"].strip() + "\n" body = meta["__body__"].strip() + "\n"
existing = repo.get_claude_skill_by_name(conn, name) existing = repo.get_claude_skill_by_name(conn, name)
if existing is None: if existing is None:
row = repo.create_claude_skill(conn, name, body, description=description) row = repo.create_claude_skill(
conn, name, body, description=description, owner_user_id=owner_user_id
)
action = "created" action = "created"
else: else:
row = repo.update_claude_skill( row = repo.update_claude_skill(
@@ -205,11 +210,12 @@ def import_staged(staging_dir: str, conn: Connection) -> list[dict]:
return results return results
def run(prompt: str) -> dict: def run(prompt: str, owner_user_id: int | None = None) -> dict:
"""The whole flow: stage, run the wrapped prompt through headless claude, import. """The whole flow: stage, run the wrapped prompt through headless claude, import.
Returns ``{"skills": [...], "summary": <claude's closing report>}``; raises Returns ``{"skills": [...], "summary": <claude's closing report>}``; raises
InstallError when the run fails or fetched nothing importable. InstallError when the run fails or fetched nothing importable. Imported skills
belong to ``owner_user_id`` (None = shared).
""" """
prompt = (prompt or "").strip() prompt = (prompt or "").strip()
if not prompt: if not prompt:
@@ -221,7 +227,7 @@ def run(prompt: str) -> dict:
output = _run_claude(_WRAPPER.format(prompt=prompt), staging, settings_path) output = _run_claude(_WRAPPER.format(prompt=prompt), staging, settings_path)
os.remove(settings_path) # never importable, but keep the scan surface clean os.remove(settings_path) # never importable, but keep the scan surface clean
with connection() as conn: with connection() as conn:
skills = import_staged(staging, conn) skills = import_staged(staging, conn, owner_user_id=owner_user_id)
if not skills: if not skills:
raise InstallError( raise InstallError(
"the install run finished but no <skill>/SKILL.md landed in the staging " "the install run finished but no <skill>/SKILL.md landed in the staging "
+3 -2
View File
@@ -158,7 +158,8 @@ def spawn(
# Materialize the web-managed Claude config (MCP connectors + user-level skills) # Materialize the web-managed Claude config (MCP connectors + user-level skills)
# so this launch picks up what the operator configured in the dashboard. The skills # so this launch picks up what the operator configured in the dashboard. The skills
# half also feeds pi-harness agents (their settings.json points at the same dir). # half also feeds pi-harness agents (their settings.json points at the same dir).
claude_gen.apply(working_dir) # Scoped to the project's owner: shared rows plus theirs, nobody else's.
claude_gen.apply(working_dir, visible_to=project.get("owner_user_id"))
env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init) env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init)
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift # Verify the pinned forge version, if one is configured. Non-fatal: a version drift
@@ -284,7 +285,7 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
working_dir = agent["working_dir"] working_dir = agent["working_dir"]
settings_path = settings_gen.write_settings(working_dir) settings_path = settings_gen.write_settings(working_dir)
claude_gen.apply(working_dir) claude_gen.apply(working_dir, visible_to=project.get("owner_user_id"))
try: try:
token = None token = None
with connection() as conn: with connection() as conn:
+7 -4
View File
@@ -251,12 +251,14 @@ def _cmd_login_submit(command: dict) -> dict:
def _cmd_skill_install(command: dict) -> dict: def _cmd_skill_install(command: dict) -> dict:
"""Run a pasted marketplace install prompt through a one-off headless claude and """Run a pasted marketplace install prompt through a one-off headless claude and
import the fetched skills as managed rows (Claude page, Skills tab).""" import the fetched skills as managed rows (Claude page, Skills tab). Imported rows
prompt = _payload(command).get("prompt") belong to the requesting user (``owner_user_id`` in the payload; None = shared)."""
payload = _payload(command)
prompt = payload.get("prompt")
if not prompt or not str(prompt).strip(): if not prompt or not str(prompt).strip():
raise CommandError("skill_install requires a 'prompt' in the payload") raise CommandError("skill_install requires a 'prompt' in the payload")
try: try:
return skill_install.run(str(prompt)) return skill_install.run(str(prompt), owner_user_id=payload.get("owner_user_id"))
except skill_install.InstallError as exc: except skill_install.InstallError as exc:
raise CommandError(str(exc)) from exc raise CommandError(str(exc)) from exc
@@ -488,7 +490,8 @@ def run(
pass pass
did_work = drain(worker_id) > 0 did_work = drain(worker_id) > 0
now = time.monotonic() now = time.monotonic()
if credsync_interval > 0 and (last_credsync == 0.0 or now - last_credsync >= credsync_interval): credsync_due = last_credsync == 0.0 or now - last_credsync >= credsync_interval
if credsync_interval > 0 and credsync_due:
# First pass runs immediately: a fresh worker container must materialize the # First pass runs immediately: a fresh worker container must materialize the
# claude credentials before it claims its first spawn. # claude credentials before it claims its first spawn.
try: try:
+302 -11
View File
@@ -26,6 +26,8 @@ from .tables import (
agent_runs, agent_runs,
agents, agents,
approvals, approvals,
auth_sessions,
auth_tokens,
checkmarks, checkmarks,
claude_config, claude_config,
claude_connectors, claude_connectors,
@@ -43,6 +45,7 @@ from .tables import (
schedules, schedules,
session_archives, session_archives,
shared_context, shared_context,
users,
workers, workers,
) )
from .upsert import upsert_checkmark from .upsert import upsert_checkmark
@@ -52,6 +55,21 @@ def _now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
# Sentinel for "no ownership filter" (admins, legacy tokens, and internal callers).
# ``visible_to=<user id>`` narrows a listing to shared rows (owner NULL) plus that
# user's own; ``visible_to=None`` means shared rows only (launches of a shared project).
VISIBLE_ALL = object()
def _owner_scope(owner_column, visible_to):
"""WHERE clause for the shared-plus-mine visibility rule, or None for no filter."""
if visible_to is VISIBLE_ALL:
return None
if visible_to is None:
return owner_column.is_(None)
return owner_column.is_(None) | (owner_column == visible_to)
def _row_to_dict(row) -> dict[str, Any] | None: def _row_to_dict(row) -> dict[str, Any] | None:
return dict(row._mapping) if row is not None else None return dict(row._mapping) if row is not None else None
@@ -59,8 +77,12 @@ def _row_to_dict(row) -> dict[str, Any] | None:
# --------------------------------------------------------------------------- reads # --------------------------------------------------------------------------- reads
def list_projects(conn: Connection) -> list[dict]: def list_projects(conn: Connection, visible_to=VISIBLE_ALL) -> list[dict]:
rows = conn.execute(select(projects).order_by(projects.c.id)).all() stmt = select(projects)
scope = _owner_scope(projects.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(projects.c.id)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -192,6 +214,7 @@ def create_project(
root_dir: str, root_dir: str,
git_remote: str | None = None, git_remote: str | None = None,
credential_ref: str | None = None, credential_ref: str | None = None,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
conn.execute( conn.execute(
projects.insert().values( projects.insert().values(
@@ -199,6 +222,7 @@ def create_project(
root_dir=root_dir, root_dir=root_dir,
git_remote=git_remote, git_remote=git_remote,
credential_ref=credential_ref, credential_ref=credential_ref,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -362,7 +386,7 @@ def update_project(conn: Connection, project_id: str, **fields: Any) -> dict | N
Only known columns are applied; an empty patch is a no-op read. Returns the row. Only known columns are applied; an empty patch is a no-op read. Returns the row.
""" """
allowed = {"root_dir", "git_remote", "credential_ref"} allowed = {"root_dir", "git_remote", "credential_ref", "owner_user_id"}
values = {k: v for k, v in fields.items() if k in allowed} values = {k: v for k, v in fields.items() if k in allowed}
if values: if values:
conn.execute(projects.update().where(projects.c.id == project_id).values(**values)) conn.execute(projects.update().where(projects.c.id == project_id).values(**values))
@@ -499,11 +523,24 @@ def get_command(conn: Connection, command_id: int) -> dict | None:
def list_commands( def list_commands(
conn: Connection, project_id: str | None = None, limit: int = 100, offset: int = 0 conn: Connection,
project_id: str | None = None,
limit: int = 100,
offset: int = 0,
restrict_to_projects: list[str] | None = None,
or_requested_by: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""The activity feed. ``restrict_to_projects`` scopes a non-admin user's view to
commands on projects they can see plus, via ``or_requested_by``, non-project
commands they enqueued themselves (e.g. a skill install), so they can track them."""
stmt = select(commands) stmt = select(commands)
if project_id is not None: if project_id is not None:
stmt = stmt.where(commands.c.project_id == project_id) stmt = stmt.where(commands.c.project_id == project_id)
if restrict_to_projects is not None:
scope = commands.c.project_id.in_(restrict_to_projects)
if or_requested_by is not None:
scope = scope | (commands.c.requested_by == or_requested_by)
stmt = stmt.where(scope)
rows = conn.execute( rows = conn.execute(
stmt.order_by(commands.c.id.desc()).limit(limit).offset(offset) stmt.order_by(commands.c.id.desc()).limit(limit).offset(offset)
).all() ).all()
@@ -971,10 +1008,15 @@ def get_runtime_secret(conn: Connection, key: str) -> dict | None:
# --------------------------------------------------- claude management (web-managed) # --------------------------------------------------- claude management (web-managed)
def list_claude_skills(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_skills(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_skills) stmt = select(claude_skills)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_skills.c.enabled.is_(True)) stmt = stmt.where(claude_skills.c.enabled.is_(True))
scope = _owner_scope(claude_skills.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(claude_skills.c.name)).all() rows = conn.execute(stmt.order_by(claude_skills.c.name)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -995,6 +1037,7 @@ def create_claude_skill(
content: str, content: str,
description: str | None = None, description: str | None = None,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
now = _now() now = _now()
result = conn.execute( result = conn.execute(
@@ -1003,6 +1046,7 @@ def create_claude_skill(
description=description, description=description,
content=content, content=content,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -1047,10 +1091,15 @@ def set_claude_skill_files(conn: Connection, skill_id: int, files: dict[str, str
) )
def list_claude_connectors(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_connectors(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_connectors) stmt = select(claude_connectors)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_connectors.c.enabled.is_(True)) stmt = stmt.where(claude_connectors.c.enabled.is_(True))
scope = _owner_scope(claude_connectors.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(claude_connectors.c.name)).all() rows = conn.execute(stmt.order_by(claude_connectors.c.name)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -1079,6 +1128,7 @@ def create_claude_connector(
url: str | None = None, url: str | None = None,
headers: dict | None = None, headers: dict | None = None,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
result = conn.execute( result = conn.execute(
claude_connectors.insert().values( claude_connectors.insert().values(
@@ -1090,6 +1140,7 @@ def create_claude_connector(
url=url, url=url,
headers=headers, headers=headers,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -1115,10 +1166,15 @@ def delete_claude_connector(conn: Connection, connector_id: int) -> bool:
return result.rowcount > 0 return result.rowcount > 0
def list_claude_plugins(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_plugins(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_plugins) stmt = select(claude_plugins)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_plugins.c.enabled.is_(True)) stmt = stmt.where(claude_plugins.c.enabled.is_(True))
scope = _owner_scope(claude_plugins.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute( rows = conn.execute(
stmt.order_by(claude_plugins.c.marketplace, claude_plugins.c.name) stmt.order_by(claude_plugins.c.marketplace, claude_plugins.c.name)
).all() ).all()
@@ -1145,6 +1201,7 @@ def create_claude_plugin(
marketplace: str, marketplace: str,
marketplace_repo: str, marketplace_repo: str,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
result = conn.execute( result = conn.execute(
claude_plugins.insert().values( claude_plugins.insert().values(
@@ -1152,6 +1209,7 @@ def create_claude_plugin(
marketplace=marketplace, marketplace=marketplace,
marketplace_repo=marketplace_repo, marketplace_repo=marketplace_repo,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -1173,10 +1231,15 @@ def delete_claude_plugin(conn: Connection, plugin_id: int) -> bool:
return result.rowcount > 0 return result.rowcount > 0
def list_claude_models(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_models(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_models) stmt = select(claude_models)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_models.c.enabled.is_(True)) stmt = stmt.where(claude_models.c.enabled.is_(True))
scope = _owner_scope(claude_models.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(claude_models.c.name)).all() rows = conn.execute(stmt.order_by(claude_models.c.name)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -1201,6 +1264,7 @@ def create_claude_model(
harness: str = "claude", harness: str = "claude",
env: dict | None = None, env: dict | None = None,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
result = conn.execute( result = conn.execute(
claude_models.insert().values( claude_models.insert().values(
@@ -1212,6 +1276,7 @@ def create_claude_model(
harness=harness, harness=harness,
env=env, env=env,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -1278,16 +1343,24 @@ def list_memory_notes(
include_global: bool = True, include_global: bool = True,
limit: int = 200, limit: int = 200,
offset: int = 0, offset: int = 0,
visible_project_ids: list[str] | None = None,
) -> list[dict]: ) -> list[dict]:
"""Notes in scope, newest first. ``project_id=None`` means everything (the dashboard """Notes in scope, newest first. ``project_id=None`` means everything (the dashboard
graph); a project id narrows to that project plus the global notes unless told not graph); a project id narrows to that project plus the global notes unless told not
to (the MCP server's read scope: my project + what everyone shares).""" to (the MCP server's read scope: my project + what everyone shares).
``visible_project_ids`` further restricts project notes to those projects (a
non-admin user's view); global notes always pass."""
stmt = select(memory_notes) stmt = select(memory_notes)
if project_id is not None: if project_id is not None:
scope = memory_notes.c.project_id == project_id scope = memory_notes.c.project_id == project_id
if include_global: if include_global:
scope = scope | memory_notes.c.project_id.is_(None) scope = scope | memory_notes.c.project_id.is_(None)
stmt = stmt.where(scope) stmt = stmt.where(scope)
if visible_project_ids is not None:
stmt = stmt.where(
memory_notes.c.project_id.is_(None)
| memory_notes.c.project_id.in_(visible_project_ids)
)
rows = conn.execute( rows = conn.execute(
stmt.order_by(memory_notes.c.id.desc()).limit(limit).offset(offset) stmt.order_by(memory_notes.c.id.desc()).limit(limit).offset(offset)
).all() ).all()
@@ -1305,6 +1378,7 @@ def search_memory_notes(
project_id: str | None = None, project_id: str | None = None,
include_global: bool = True, include_global: bool = True,
limit: int = 20, limit: int = 20,
visible_project_ids: list[str] | None = None,
) -> list[dict]: ) -> list[dict]:
"""Case-insensitive substring search over title/body/kind, every term required. """Case-insensitive substring search over title/body/kind, every term required.
@@ -1319,6 +1393,11 @@ def search_memory_notes(
if include_global: if include_global:
scope = scope | memory_notes.c.project_id.is_(None) scope = scope | memory_notes.c.project_id.is_(None)
stmt = stmt.where(scope) stmt = stmt.where(scope)
if visible_project_ids is not None:
stmt = stmt.where(
memory_notes.c.project_id.is_(None)
| memory_notes.c.project_id.in_(visible_project_ids)
)
for term in terms: for term in terms:
pattern = f"%{term}%" pattern = f"%{term}%"
stmt = stmt.where( stmt = stmt.where(
@@ -1429,10 +1508,222 @@ def delete_memory_link(conn: Connection, link_id: int) -> bool:
return result.rowcount > 0 return result.rowcount > 0
def memory_graph(conn: Connection, project_id: str | None = None) -> dict: # ------------------------------------------------------------- user accounts & sessions
def count_users(conn: Connection) -> int:
from sqlalchemy import func as sqlfunc
return conn.execute(select(sqlfunc.count()).select_from(users)).scalar_one()
def list_users(conn: Connection) -> list[dict]:
rows = conn.execute(select(users).order_by(users.c.id)).all()
return [dict(r._mapping) for r in rows]
def get_user(conn: Connection, user_id: int) -> dict | None:
row = conn.execute(select(users).where(users.c.id == user_id)).first()
return _row_to_dict(row)
def get_user_by_email(conn: Connection, email: str) -> dict | None:
row = conn.execute(
select(users).where(users.c.email == email.strip().lower())
).first()
return _row_to_dict(row)
def create_user(
conn: Connection,
email: str,
password_hash: str | None = None,
is_admin: bool = False,
) -> dict:
result = conn.execute(
users.insert().values(
email=email.strip().lower(),
password_hash=password_hash,
is_admin=is_admin,
disabled=False,
created_at=_now(),
)
)
return get_user(conn, result.inserted_primary_key[0])
def update_user(conn: Connection, user_id: int, **fields: Any) -> dict | None:
allowed = {"password_hash", "is_admin", "disabled"}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
conn.execute(users.update().where(users.c.id == user_id).values(**values))
return get_user(conn, user_id)
def count_active_admins(conn: Connection, exclude_user_id: int | None = None) -> int:
"""Enabled admin accounts with a usable password — the lockout guard's input (an
invited admin who never set a password can't sign in, so they don't count)."""
from sqlalchemy import func as sqlfunc
stmt = (
select(sqlfunc.count())
.select_from(users)
.where(
users.c.is_admin.is_(True),
users.c.disabled.is_(False),
users.c.password_hash.is_not(None),
)
)
if exclude_user_id is not None:
stmt = stmt.where(users.c.id != exclude_user_id)
return conn.execute(stmt).scalar_one()
def delete_user(conn: Connection, user_id: int) -> bool:
"""Remove an account. Rows it owned become *shared* (owner NULL) rather than
disappearing the ownership columns carry no FK precisely so a deleted user can
never orphan a project or break an agent's next launch. Sessions and one-shot
links die with the account."""
for table in (projects, claude_skills, claude_connectors, claude_plugins, claude_models):
conn.execute(
table.update()
.where(table.c.owner_user_id == user_id)
.values(owner_user_id=None)
)
conn.execute(auth_sessions.delete().where(auth_sessions.c.user_id == user_id))
conn.execute(auth_tokens.delete().where(auth_tokens.c.user_id == user_id))
result = conn.execute(users.delete().where(users.c.id == user_id))
return result.rowcount > 0
def create_auth_session(
conn: Connection, user_id: int, token_hash: str, expires_at: datetime
) -> dict:
result = conn.execute(
auth_sessions.insert().values(
user_id=user_id,
token_hash=token_hash,
created_at=_now(),
expires_at=expires_at,
)
)
row = conn.execute(
select(auth_sessions).where(auth_sessions.c.id == result.inserted_primary_key[0])
).first()
return dict(row._mapping)
def get_session_user(conn: Connection, token_hash: str) -> dict | None:
"""The (enabled) user behind a live session token hash, or None. The session row's
``expires_at``/``last_used_at`` ride along under prefixed keys for the caller."""
row = conn.execute(
select(
users,
auth_sessions.c.expires_at.label("session_expires_at"),
auth_sessions.c.last_used_at.label("session_last_used_at"),
)
.select_from(auth_sessions.join(users, auth_sessions.c.user_id == users.c.id))
.where(
auth_sessions.c.token_hash == token_hash,
auth_sessions.c.expires_at > _now(),
users.c.disabled.is_(False),
)
).first()
return _row_to_dict(row)
def touch_auth_session(conn: Connection, token_hash: str) -> None:
conn.execute(
auth_sessions.update()
.where(auth_sessions.c.token_hash == token_hash)
.values(last_used_at=_now())
)
def delete_auth_session(conn: Connection, token_hash: str) -> bool:
result = conn.execute(
auth_sessions.delete().where(auth_sessions.c.token_hash == token_hash)
)
return result.rowcount > 0
def delete_user_sessions(
conn: Connection, user_id: int, keep_token_hash: str | None = None
) -> int:
"""Log a user out everywhere (password change / reset), optionally keeping the
session doing the changing."""
stmt = auth_sessions.delete().where(auth_sessions.c.user_id == user_id)
if keep_token_hash is not None:
stmt = stmt.where(auth_sessions.c.token_hash != keep_token_hash)
return conn.execute(stmt).rowcount
def purge_expired_sessions(conn: Connection) -> int:
"""Housekeeping, piggybacked on logins so no scheduler is needed."""
now = _now()
expired = conn.execute(
auth_sessions.delete().where(auth_sessions.c.expires_at <= now)
).rowcount
conn.execute(auth_tokens.delete().where(auth_tokens.c.expires_at <= now))
return expired
def create_auth_token(
conn: Connection, user_id: int, token_hash: str, purpose: str, expires_at: datetime
) -> dict:
"""Mint a one-shot link token (``reset`` or ``invite``), superseding any earlier
unused ones of the same purpose so only the latest emailed link works."""
conn.execute(
auth_tokens.delete().where(
auth_tokens.c.user_id == user_id,
auth_tokens.c.purpose == purpose,
auth_tokens.c.used_at.is_(None),
)
)
result = conn.execute(
auth_tokens.insert().values(
user_id=user_id,
token_hash=token_hash,
purpose=purpose,
expires_at=expires_at,
created_at=_now(),
)
)
row = conn.execute(
select(auth_tokens).where(auth_tokens.c.id == result.inserted_primary_key[0])
).first()
return dict(row._mapping)
def consume_auth_token(conn: Connection, token_hash: str) -> dict | None:
"""Atomically spend a valid, unused link token; None if unknown/expired/spent."""
result = conn.execute(
auth_tokens.update()
.where(
auth_tokens.c.token_hash == token_hash,
auth_tokens.c.used_at.is_(None),
auth_tokens.c.expires_at > _now(),
)
.values(used_at=_now())
)
if result.rowcount != 1:
return None
row = conn.execute(
select(auth_tokens).where(auth_tokens.c.token_hash == token_hash)
).first()
return _row_to_dict(row)
def memory_graph(
conn: Connection,
project_id: str | None = None,
visible_project_ids: list[str] | None = None,
) -> dict:
"""The whole graph in one read — what the /memory page draws. Scoping to a project """The whole graph in one read — what the /memory page draws. Scoping to a project
keeps its notes plus the global ones, and only edges with both endpoints in scope.""" keeps its notes plus the global ones, and only edges with both endpoints in scope."""
notes = list_memory_notes(conn, project_id=project_id, limit=1000) notes = list_memory_notes(
conn, project_id=project_id, limit=1000, visible_project_ids=visible_project_ids
)
ids = [n["id"] for n in notes] ids = [n["id"] for n in notes]
links = list_memory_links(conn, note_ids=ids) links = list_memory_links(conn, note_ids=ids)
in_scope = set(ids) in_scope = set(ids)
+59
View File
@@ -72,6 +72,54 @@ def _in(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN ({joined})" return f"{column} IN ({joined})"
# ---- User accounts (email + password). The first account created (the setup flow)
# is the admin; every later account is created by an admin. ``password_hash`` is null
# until an invited user sets a password through their invite link. Ownership columns
# elsewhere (``owner_user_id``) reference ``users.id`` *without* an FK — same rationale
# as ``agents.model_id``: deleting a user must never orphan or cascade away resources,
# so ``delete_user`` explicitly reassigns owned rows to shared (NULL) instead.
users = Table(
"users",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("email", String, nullable=False, unique=True), # stored lowercased
Column("password_hash", String), # scrypt (handler.authn); null = invite not accepted
Column("is_admin", Boolean, nullable=False, server_default="0"),
Column("disabled", Boolean, nullable=False, server_default="0"),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
# Browser sessions. The API hands out a random bearer token at login and stores only its
# SHA-256 here, so a database dump never contains a usable session credential.
auth_sessions = Table(
"auth_sessions",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("user_id", BigInteger, ForeignKey("users.id"), nullable=False),
Column("token_hash", String, nullable=False, unique=True),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
Column("expires_at", PortableTimestamp, nullable=False),
Column("last_used_at", PortableTimestamp),
)
# One-shot links: password resets and invites (an invite is just a longer-lived reset on
# an account that has no password yet). Hash-stored like sessions; ``used_at`` makes them
# single-use.
AUTH_TOKEN_PURPOSES = ("reset", "invite")
auth_tokens = Table(
"auth_tokens",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("user_id", BigInteger, ForeignKey("users.id"), nullable=False),
Column("token_hash", String, nullable=False, unique=True),
Column("purpose", String, nullable=False),
Column("expires_at", PortableTimestamp, nullable=False),
Column("used_at", PortableTimestamp),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
CheckConstraint(_in("purpose", AUTH_TOKEN_PURPOSES), name="ck_auth_tokens_purpose"),
)
projects = Table( projects = Table(
"projects", "projects",
metadata, metadata,
@@ -80,6 +128,9 @@ projects = Table(
Column("git_remote", String), Column("git_remote", String),
# Pointer to a secret (env:VAR / file:/path / cmd:...), never the token — README 3.7. # Pointer to a secret (env:VAR / file:/path / cmd:...), never the token — README 3.7.
Column("credential_ref", String), Column("credential_ref", String),
# Owning user account; null = shared/legacy (visible to everyone, admin-managed).
# No FK by design — see the ``users`` table comment.
Column("owner_user_id", BigInteger),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
) )
@@ -354,6 +405,8 @@ claude_skills = Table(
Column("description", String), Column("description", String),
Column("content", String, nullable=False), # markdown body below the front-matter Column("content", String, nullable=False), # markdown body below the front-matter
Column("enabled", Boolean, nullable=False, server_default="1"), Column("enabled", Boolean, nullable=False, server_default="1"),
# Owning user; null = shared (synced for every user's agents). No FK — see ``users``.
Column("owner_user_id", BigInteger),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
) )
@@ -390,6 +443,8 @@ claude_connectors = Table(
Column("url", String), # http/sse: the endpoint Column("url", String), # http/sse: the endpoint
Column("headers", PortableJSON), # http/sse: header map (may carry auth) Column("headers", PortableJSON), # http/sse: header map (may carry auth)
Column("enabled", Boolean, nullable=False, server_default="1"), Column("enabled", Boolean, nullable=False, server_default="1"),
# Owning user; null = shared (applied to every user's launches). No FK — see ``users``.
Column("owner_user_id", BigInteger),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
CheckConstraint(_in("transport", MCP_TRANSPORTS), name="ck_claude_connectors_transport"), CheckConstraint(_in("transport", MCP_TRANSPORTS), name="ck_claude_connectors_transport"),
) )
@@ -404,6 +459,8 @@ claude_plugins = Table(
Column("marketplace", String, nullable=False), # marketplace key, e.g. "acme-tools" Column("marketplace", String, nullable=False), # marketplace key, e.g. "acme-tools"
Column("marketplace_repo", String, nullable=False), # "owner/repo" or a git URL Column("marketplace_repo", String, nullable=False), # "owner/repo" or a git URL
Column("enabled", Boolean, nullable=False, server_default="1"), Column("enabled", Boolean, nullable=False, server_default="1"),
# Owning user; null = shared. No FK — see ``users``.
Column("owner_user_id", BigInteger),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"), UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"),
) )
@@ -430,6 +487,8 @@ claude_models = Table(
Column("harness", String, nullable=False, server_default="claude"), Column("harness", String, nullable=False, server_default="claude"),
Column("env", PortableJSON), # extra env overrides (timeouts, max tokens, …), merged last Column("env", PortableJSON), # extra env overrides (timeouts, max tokens, …), merged last
Column("enabled", Boolean, nullable=False, server_default="1"), Column("enabled", Boolean, nullable=False, server_default="1"),
# Owning user; null = shared (offered in every user's spawn dropdown). No FK — see ``users``.
Column("owner_user_id", BigInteger),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
) )
+55
View File
@@ -0,0 +1,55 @@
"""Outbound email (invites + password resets) over plain SMTP.
Stdlib ``smtplib`` behind one function, configured entirely from the environment
(``SMTP_HOST`` et al see ``config.Settings``). When SMTP is not configured the API
degrades gracefully: admin-facing flows return the invite/reset *link* in the response
instead of mailing it, and the self-serve forgot-password flow reports that email is
unavailable. Nothing in the control layer depends on this module.
"""
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from email.utils import formatdate
from .config import Settings, get_settings
class EmailError(Exception):
"""SMTP delivery failed (or email is not configured)."""
def configured(settings: Settings | None = None) -> bool:
settings = settings or get_settings()
return bool(settings.smtp_host and settings.smtp_from)
def send(to: str, subject: str, body: str, settings: Settings | None = None) -> None:
"""Deliver one plain-text message; raises :class:`EmailError` on any failure."""
settings = settings or get_settings()
if not configured(settings):
raise EmailError("SMTP is not configured (set SMTP_HOST and SMTP_FROM)")
message = EmailMessage()
message["From"] = settings.smtp_from
message["To"] = to
message["Subject"] = subject
message["Date"] = formatdate(localtime=True)
message.set_content(body)
try:
if settings.smtp_ssl:
client: smtplib.SMTP = smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port, timeout=15
)
else:
client = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15)
with client:
if settings.smtp_starttls and not settings.smtp_ssl:
client.starttls()
if settings.smtp_username:
client.login(settings.smtp_username, settings.smtp_password or "")
client.send_message(message)
except (OSError, smtplib.SMTPException) as exc:
raise EmailError(f"could not send email via {settings.smtp_host}: {exc}") from exc
@@ -0,0 +1,79 @@
"""user accounts: email login, sessions, reset/invite links, per-user ownership
Revision ID: 0016_user_accounts
Revises: 0015_model_harness
Create Date: 2026-08-12
Replaces "know the API key" with email + password accounts: the first account created
becomes the admin, later accounts are created by an admin (invite links), and password
resets ride the same one-shot-token table. Resources gain a nullable ``owner_user_id``
(projects, skills, connectors, plugins, model backends) null means shared/legacy, so
an upgraded deployment behaves exactly as before until users start owning things. The
legacy env tokens keep working for scripts/CI; no data backfill is needed.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from handler.db.types import PortableBigInt, PortableTimestamp
revision: str = "0016_user_accounts"
down_revision: str | None = "0015_model_harness"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Tables that gain per-user ownership. Nullable, no FK (mirrors agents.model_id: a
# deleted user must never orphan resources — delete_user reassigns rows to shared).
_OWNED_TABLES = (
"projects",
"claude_skills",
"claude_connectors",
"claude_plugins",
"claude_models",
)
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("email", sa.String(), nullable=False, unique=True),
sa.Column("password_hash", sa.String()),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("disabled", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
)
op.create_table(
"auth_sessions",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("token_hash", sa.String(), nullable=False, unique=True),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", PortableTimestamp, nullable=False),
sa.Column("last_used_at", PortableTimestamp),
)
op.create_table(
"auth_tokens",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("token_hash", sa.String(), nullable=False, unique=True),
sa.Column("purpose", sa.String(), nullable=False),
sa.Column("expires_at", PortableTimestamp, nullable=False),
sa.Column("used_at", PortableTimestamp),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("purpose IN ('reset', 'invite')", name="ck_auth_tokens_purpose"),
)
for table in _OWNED_TABLES:
op.add_column(table, sa.Column("owner_user_id", sa.BigInteger()))
def downgrade() -> None:
for table in _OWNED_TABLES:
op.drop_column(table, "owner_user_id")
op.drop_table("auth_tokens")
op.drop_table("auth_sessions")
op.drop_table("users")
+281
View File
@@ -0,0 +1,281 @@
"""Per-user separation: users see shared resources plus their own — never each
other's — across projects, agents, schedules, memory, activity, and the Claude page
resources; the control layer applies only the owner's rows at launch."""
from __future__ import annotations
import pytest
@pytest.fixture
def users(client):
"""Three actors: an admin, and two regular users (alice, bob)."""
r = client.post("/auth/setup", json={"email": "admin@x.co", "password": "admin-pass-1"})
admin = {"Authorization": f"Bearer {r.json()['token']}"}
out = {"admin": admin, "admin_user": r.json()["user"]}
for name in ("alice", "bob"):
invite = client.post(
"/auth/users", json={"email": f"{name}@x.co"}, headers=admin
).json()
token = invite["invite_url"].split("token=")[1]
r = client.post("/auth/reset", json={"token": token, "password": f"{name}-pass-1"})
out[name] = {"Authorization": f"Bearer {r.json()['token']}"}
out[f"{name}_user"] = r.json()["user"]
return out
def _mkproject(client, headers, pid):
r = client.post("/projects", json={"id": pid, "root_dir": f"/tmp/{pid}"}, headers=headers)
assert r.status_code == 201
return r.json()
# ---- projects --------------------------------------------------------------------------
def test_projects_are_invisible_across_users(client, users):
_mkproject(client, users["alice"], "alices")
_mkproject(client, users["bob"], "bobs")
assert [p["id"] for p in client.get("/projects", headers=users["alice"]).json()] == ["alices"]
assert [p["id"] for p in client.get("/projects", headers=users["bob"]).json()] == ["bobs"]
# Existence is not leaked: someone else's project 404s, as do its nested routes.
assert client.get("/projects/bobs", headers=users["alice"]).status_code == 404
assert client.get("/projects/bobs/agents", headers=users["alice"]).status_code == 404
assert client.delete("/projects/bobs", headers=users["alice"]).status_code == 404
# Admin sees and can manage everything.
assert {p["id"] for p in client.get("/projects", headers=users["admin"]).json()} == {
"alices", "bobs",
}
def test_shared_projects_visible_but_admin_managed(client, users, env):
token_headers = {"Authorization": f"Bearer {env['token']}"}
_mkproject(client, token_headers, "sharedproj") # env token => shared (owner NULL)
assert client.get("/projects/sharedproj", headers=users["alice"]).status_code == 200
# Viewing yes; mutating no — shared rows are admin-managed.
r = client.patch(
"/projects/sharedproj", json={"root_dir": "/tmp/x"}, headers=users["alice"]
)
assert r.status_code == 403
assert client.patch(
"/projects/sharedproj", json={"root_dir": "/tmp/x"}, headers=users["admin"]
).status_code == 200
def test_owner_operates_own_project_without_admin(client, users, fake_launch, tmp_path):
"""A regular user drives the full lifecycle on their own project (spawn needs a
worker; here we only prove the API-side gates: enqueue allowed, 404 for others)."""
_mkproject(client, users["alice"], "alices")
r = client.post(
"/projects/alices/agents/spawn",
json={"name": "a1", "task": "do the thing"},
headers=users["alice"],
)
assert r.status_code == 202
assert r.json()["requested_by"].startswith("user:")
# Bob can't even see the project, let alone spawn into it.
r = client.post(
"/projects/alices/agents/spawn",
json={"name": "b1", "task": "sneaky"},
headers=users["bob"],
)
assert r.status_code == 404
def test_admin_can_reassign_owner(client, users):
_mkproject(client, users["alice"], "alices")
bob_id = users["bob_user"]["id"]
r = client.patch(
"/projects/alices", json={"owner_user_id": bob_id}, headers=users["admin"]
)
assert r.status_code == 200 and r.json()["owner_user_id"] == bob_id
assert client.get("/projects/alices", headers=users["alice"]).status_code == 404
assert client.get("/projects/alices", headers=users["bob"]).status_code == 200
# Owners themselves cannot hand projects around.
r = client.patch(
"/projects/alices", json={"owner_user_id": None}, headers=users["bob"]
)
assert r.status_code == 403
# ---- claude page resources (skills / connectors / plugins / models) --------------------
def test_skills_are_separated_and_shared_rows_common(client, users, env):
token_headers = {"Authorization": f"Bearer {env['token']}"}
mk = lambda h, name: client.post( # noqa: E731
"/claude/skills", json={"name": name, "content": "# x"}, headers=h
)
assert mk(token_headers, "shared-skill").status_code == 201
alice_skill = mk(users["alice"], "alice-skill")
assert alice_skill.status_code == 201
assert alice_skill.json()["owner_user_id"] == users["alice_user"]["id"]
assert mk(users["bob"], "bob-skill").status_code == 201
names = lambda h: {s["name"] for s in client.get("/claude/skills", headers=h).json()} # noqa: E731
assert names(users["alice"]) == {"shared-skill", "alice-skill"}
assert names(users["bob"]) == {"shared-skill", "bob-skill"}
assert names(users["admin"]) == {"shared-skill", "alice-skill", "bob-skill"}
# Cross-user mutation 404s (invisible); shared mutation 403s for non-admins.
alice_id = alice_skill.json()["id"]
assert client.delete(f"/claude/skills/{alice_id}", headers=users["bob"]).status_code == 404
shared_id = next(
s["id"] for s in client.get("/claude/skills", headers=users["admin"]).json()
if s["name"] == "shared-skill"
)
assert client.patch(
f"/claude/skills/{shared_id}", json={"enabled": False}, headers=users["alice"]
).status_code == 403
assert client.delete(f"/claude/skills/{alice_id}", headers=users["alice"]).status_code == 200
def test_connectors_plugins_models_follow_same_rules(client, users):
a, b = users["alice"], users["bob"]
r = client.post(
"/claude/connectors",
json={"name": "alice-mcp", "transport": "http", "url": "https://a.example/mcp"},
headers=a,
)
assert r.status_code == 201
r = client.post(
"/claude/plugins",
json={"name": "alice-plug", "marketplace": "mp", "marketplace_repo": "o/r"},
headers=a,
)
assert r.status_code == 201
r = client.post(
"/claude/models",
json={"name": "alice-model", "base_url": "http://localhost:4000", "model": "m"},
headers=a,
)
assert r.status_code == 201
model_id = r.json()["id"]
assert client.get("/claude/connectors", headers=b).json() == []
assert client.get("/claude/plugins", headers=b).json() == []
assert client.get("/claude/models", headers=b).json() == []
# Bob can't spawn or schedule onto Alice's private model backend.
_mkproject(client, b, "bobs")
r = client.post(
"/projects/bobs/agents/spawn",
json={"name": "b1", "task": "t", "model_id": model_id},
headers=b,
)
assert r.status_code == 400 and "not found" in r.json()["detail"]
r = client.post(
"/projects/bobs/schedules",
json={"name_prefix": "s", "task": "t", "interval_seconds": 3600, "model_id": model_id},
headers=b,
)
assert r.status_code == 400
# ---- schedules, activity, memory -------------------------------------------------------
def test_schedules_follow_project_visibility(client, users):
_mkproject(client, users["alice"], "alices")
r = client.post(
"/projects/alices/schedules",
json={"name_prefix": "nightly", "task": "t", "interval_seconds": 3600},
headers=users["alice"],
)
assert r.status_code == 201
sid = r.json()["id"]
assert client.get("/schedules", headers=users["bob"]).json() == []
assert client.get("/projects/alices/schedules", headers=users["bob"]).status_code == 404
assert client.patch(
f"/schedules/{sid}", json={"enabled": False}, headers=users["bob"]
).status_code == 404
assert len(client.get("/schedules", headers=users["admin"]).json()) == 1
assert client.delete(f"/schedules/{sid}", headers=users["alice"]).status_code == 200
def test_activity_feed_is_scoped(client, users):
_mkproject(client, users["alice"], "alices")
r = client.post(
"/projects/alices/agents/spawn",
json={"name": "a1", "task": "t"},
headers=users["alice"],
)
cmd_id = r.json()["id"]
assert client.get("/commands", headers=users["bob"]).json() == []
assert client.get(f"/commands/{cmd_id}", headers=users["bob"]).status_code == 404
assert client.get(f"/commands/{cmd_id}", headers=users["alice"]).status_code == 200
assert len(client.get("/commands", headers=users["admin"]).json()) == 1
def test_memory_notes_follow_project_visibility(client, users):
_mkproject(client, users["alice"], "alices")
r = client.post(
"/memory/notes",
json={"title": "alice fact", "body": "b", "kind": "fact", "project_id": "alices"},
headers=users["alice"],
)
assert r.status_code == 201
note_id = r.json()["id"]
# Global notes reach every user's agents, so only admins write them.
r = client.post(
"/memory/notes", json={"title": "global", "body": "b", "kind": "fact"},
headers=users["alice"],
)
assert r.status_code == 403
assert client.post(
"/memory/notes", json={"title": "global", "body": "b", "kind": "fact"},
headers=users["admin"],
).status_code == 201
bob_titles = {n["title"] for n in client.get("/memory/notes", headers=users["bob"]).json()}
assert bob_titles == {"global"} # global visible, alice's project note not
assert client.get(f"/memory/notes/{note_id}", headers=users["bob"]).status_code == 404
graph = client.get("/memory/graph", headers=users["bob"]).json()
assert {n["title"] for n in graph["notes"]} == {"global"}
# ---- control layer: what a launch materializes -----------------------------------------
def test_launch_applies_only_owner_and_shared_rows(client, users, conn, tmp_path):
"""claude_gen.apply for a project owned by alice syncs shared + alice's skills and
connectors never bob's."""
from handler.control import claude_gen
from handler.db import repository as repo
alice_id = users["alice_user"]["id"]
bob_id = users["bob_user"]["id"]
repo.create_claude_skill(conn, "shared-skill", "# s")
repo.create_claude_skill(conn, "alice-skill", "# a", owner_user_id=alice_id)
repo.create_claude_skill(conn, "bob-skill", "# b", owner_user_id=bob_id)
repo.create_claude_connector(
conn, "alice-mcp", "http", url="https://a.example/mcp", owner_user_id=alice_id
)
repo.create_claude_connector(
conn, "bob-mcp", "http", url="https://b.example/mcp", owner_user_id=bob_id
)
workdir = tmp_path / "wd"
workdir.mkdir()
summary = claude_gen.apply(str(workdir), conn=conn, visible_to=alice_id)
assert summary["skills_written"] == 2 # shared + alice's
import json
import os
mcp = json.load(open(claude_gen.mcp_config_path(str(workdir))))
assert "alice-mcp" in mcp["mcpServers"] and "bob-mcp" not in mcp["mcpServers"]
skills_root = os.path.expanduser("~/.claude/skills")
synced = set(os.listdir(skills_root))
assert {"shared-skill", "alice-skill"} <= synced and "bob-skill" not in synced
# A shared/legacy project (owner None) gets shared rows only.
summary = claude_gen.apply(str(workdir), conn=conn, visible_to=None)
assert summary["skills_written"] == 1
synced = set(os.listdir(skills_root))
assert "alice-skill" not in synced and "shared-skill" in synced
+250
View File
@@ -0,0 +1,250 @@
"""User accounts: first-run setup, sign-in, sessions, resets/invites, admin management.
The email flows run with SMTP unconfigured (the default test env), which is itself a
supported mode: links are returned to the admin instead of mailed. Delivery is covered
by faking ``emailer.send`` where it matters.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def admin_session(client):
"""Complete first-run setup; returns (headers, user) for the created admin."""
r = client.post(
"/auth/setup", json={"email": "admin@example.com", "password": "admin-pass-1"}
)
assert r.status_code == 201
body = r.json()
assert body["user"]["is_admin"] is True
return {"Authorization": f"Bearer {body['token']}"}, body["user"]
def _invite(client, admin_headers, email, is_admin=False):
r = client.post(
"/auth/users", json={"email": email, "is_admin": is_admin}, headers=admin_headers
)
assert r.status_code == 201
return r.json()
def _accept(client, invite, password):
token = invite["invite_url"].split("token=")[1]
r = client.post("/auth/reset", json={"token": token, "password": password})
assert r.status_code == 200
return {"Authorization": f"Bearer {r.json()['token']}"}, r.json()["user"]
# ---- first-run setup -------------------------------------------------------------------
def test_status_flips_after_setup(client):
assert client.get("/auth/status").json()["initialized"] is False
client.post("/auth/setup", json={"email": "a@b.co", "password": "password-1"})
assert client.get("/auth/status").json()["initialized"] is True
def test_first_user_is_admin_and_second_setup_refused(client, admin_session):
headers, user = admin_session
assert user["is_admin"] is True
r = client.post("/auth/setup", json={"email": "x@y.co", "password": "password-1"})
assert r.status_code == 409
def test_setup_rejects_bad_email_and_short_password(client):
bad_email = client.post("/auth/setup", json={"email": "nope", "password": "password-1"})
assert bad_email.status_code == 422
short = client.post("/auth/setup", json={"email": "a@b.co", "password": "short"})
assert short.status_code == 422
# ---- sign-in / session lifecycle -------------------------------------------------------
def test_login_logout_me(client, admin_session):
r = client.post("/auth/login", json={"email": "Admin@Example.COM", "password": "admin-pass-1"})
assert r.status_code == 200 # email matching is case-insensitive
headers = {"Authorization": f"Bearer {r.json()['token']}"}
me = client.get("/auth/me", headers=headers).json()
assert me == {
"kind": "user", "user_id": r.json()["user"]["id"],
"email": "admin@example.com", "is_admin": True,
}
assert client.post("/auth/logout", headers=headers).status_code == 200
assert client.get("/auth/me", headers=headers).status_code == 401
def test_login_rejects_wrong_password_and_unknown_email(client, admin_session):
assert client.post(
"/auth/login", json={"email": "admin@example.com", "password": "wrong-pass"}
).status_code == 401
assert client.post(
"/auth/login", json={"email": "ghost@example.com", "password": "whatever-1"}
).status_code == 401
def test_disabled_user_cannot_login_and_live_session_dies(client, admin_session):
admin_headers, _ = admin_session
invite = _invite(client, admin_headers, "dev@example.com")
dev_headers, dev = _accept(client, invite, "dev-password-1")
r = client.patch(f"/auth/users/{dev['id']}", json={"disabled": True}, headers=admin_headers)
assert r.status_code == 200 and r.json()["disabled"] is True
assert client.post(
"/auth/login", json={"email": "dev@example.com", "password": "dev-password-1"}
).status_code == 403
# The existing session stops resolving too — disable means locked out now.
assert client.get("/auth/me", headers=dev_headers).status_code == 401
def test_change_password_revokes_other_sessions(client, admin_session):
headers, user = admin_session
other = client.post(
"/auth/login", json={"email": "admin@example.com", "password": "admin-pass-1"}
)
other_headers = {"Authorization": f"Bearer {other.json()['token']}"}
r = client.post(
"/auth/change-password",
json={"current_password": "admin-pass-1", "new_password": "admin-pass-2"},
headers=headers,
)
assert r.status_code == 200
assert client.get("/auth/me", headers=headers).status_code == 200 # this session lives
assert client.get("/auth/me", headers=other_headers).status_code == 401 # others die
assert client.post(
"/auth/login", json={"email": "admin@example.com", "password": "admin-pass-2"}
).status_code == 200
wrong = client.post(
"/auth/change-password",
json={"current_password": "nope-nope-1", "new_password": "admin-pass-3"},
headers=headers,
)
assert wrong.status_code == 403
# ---- invites & resets ------------------------------------------------------------------
def test_invite_flow_creates_usable_account(client, admin_session):
admin_headers, _ = admin_session
invite = _invite(client, admin_headers, "Dev@Example.com")
assert invite["emailed"] is False # SMTP unconfigured -> link only
assert invite["user"]["has_password"] is False
dev_headers, dev = _accept(client, invite, "dev-password-1")
assert dev["email"] == "dev@example.com" and dev["is_admin"] is False
assert client.get("/auth/me", headers=dev_headers).json()["email"] == "dev@example.com"
# The invite link is one-shot.
token = invite["invite_url"].split("token=")[1]
assert client.post(
"/auth/reset", json={"token": token, "password": "again-password-1"}
).status_code == 400
def test_invite_duplicate_email_conflicts(client, admin_session):
admin_headers, _ = admin_session
_invite(client, admin_headers, "dev@example.com")
r = client.post("/auth/users", json={"email": "DEV@example.com"}, headers=admin_headers)
assert r.status_code == 409
def test_admin_reset_link_and_forgot(client, admin_session, monkeypatch):
admin_headers, admin = admin_session
invite = _invite(client, admin_headers, "dev@example.com")
dev_headers, dev = _accept(client, invite, "dev-password-1")
# Admin-minted reset link works and revokes the old session on use.
r = client.post(f"/auth/users/{dev['id']}/reset-link", headers=admin_headers)
assert r.status_code == 200
token = r.json()["reset_url"].split("token=")[1]
reset = client.post("/auth/reset", json={"token": token, "password": "dev-password-2"})
assert reset.status_code == 200
assert client.get("/auth/me", headers=dev_headers).status_code == 401
# Self-serve forgot: without SMTP it reports emailed=False and mints nothing.
r = client.post("/auth/forgot", json={"email": "dev@example.com"})
assert r.json() == {"ok": True, "emailed": False}
# With (faked) SMTP configured, the link lands in an email — capture and use it.
sent = []
from handler import emailer
monkeypatch.setattr(emailer, "configured", lambda settings=None: True)
monkeypatch.setattr(
emailer, "send", lambda to, subject, body, settings=None: sent.append((to, subject, body))
)
r = client.post("/auth/forgot", json={"email": "dev@example.com"})
assert r.json() == {"ok": True, "emailed": True}
assert sent and sent[0][0] == "dev@example.com"
emailed_token = sent[0][2].split("token=")[1].split()[0]
assert client.post(
"/auth/reset", json={"token": emailed_token, "password": "dev-password-3"}
).status_code == 200
# Unknown addresses get the same answer and no email.
sent.clear()
assert client.post("/auth/forgot", json={"email": "ghost@example.com"}).json()["ok"] is True
assert sent == []
# ---- admin management guards -----------------------------------------------------------
def test_user_management_is_admin_only(client, admin_session):
admin_headers, _ = admin_session
invite = _invite(client, admin_headers, "dev@example.com")
dev_headers, dev = _accept(client, invite, "dev-password-1")
assert client.get("/auth/users", headers=dev_headers).status_code == 403
assert client.post(
"/auth/users", json={"email": "x@y.co"}, headers=dev_headers
).status_code == 403
assert client.patch(
f"/auth/users/{dev['id']}", json={"is_admin": True}, headers=dev_headers
).status_code == 403
listed = client.get("/auth/users", headers=admin_headers).json()
assert {u["email"] for u in listed} == {"admin@example.com", "dev@example.com"}
def test_last_admin_cannot_be_demoted_disabled_or_deleted(client, admin_session):
admin_headers, admin = admin_session
for body in ({"is_admin": False}, {"disabled": True}):
r = client.patch(f"/auth/users/{admin['id']}", json=body, headers=admin_headers)
assert r.status_code == 400, body
assert client.delete(f"/auth/users/{admin['id']}", headers=admin_headers).status_code == 400
# With a second active admin the original may step down.
invite = _invite(client, admin_headers, "admin2@example.com", is_admin=True)
_accept(client, invite, "admin2-pass-1")
r = client.patch(f"/auth/users/{admin['id']}", json={"is_admin": False}, headers=admin_headers)
assert r.status_code == 200 and r.json()["is_admin"] is False
def test_deleting_a_user_shares_their_resources(client, admin_session, conn):
admin_headers, _ = admin_session
invite = _invite(client, admin_headers, "dev@example.com")
dev_headers, dev = _accept(client, invite, "dev-password-1")
r = client.post(
"/projects", json={"id": "devproj", "root_dir": "/tmp/devproj"}, headers=dev_headers
)
assert r.status_code == 201 and r.json()["owner_user_id"] == dev["id"]
r = client.delete(f"/auth/users/{dev['id']}", headers=admin_headers)
assert r.status_code == 200
project = client.get("/projects/devproj", headers=admin_headers).json()
assert project["owner_user_id"] is None # reassigned to shared, not orphaned
assert client.get("/auth/me", headers=dev_headers).status_code == 401
def test_legacy_env_tokens_keep_working(client, admin_session, env):
token_headers = {"Authorization": f"Bearer {env['token']}"}
me = client.get("/auth/me", headers=token_headers).json()
assert me["kind"] == "token" and me["user_id"] is None
# ADMIN_TOKEN unset falls back to AUTH_TOKEN, so the env token passes admin gates.
assert client.get("/auth/users", headers=token_headers).status_code == 200
assert client.get("/projects", headers=token_headers).status_code == 200
+3 -1
View File
@@ -388,7 +388,9 @@ def test_skill_install_route_enqueues(client, auth, lowpriv):
assert r.status_code == 202 assert r.status_code == 202
body = r.json() body = r.json()
assert body["type"] == "skill_install" and body["status"] == "queued" assert body["type"] == "skill_install" and body["status"] == "queued"
assert body["payload"] == {"prompt": "Install x from y"} # Env-token installs land as shared skills (owner_user_id None); a signed-in user's
# install would carry their id here.
assert body["payload"] == {"prompt": "Install x from y", "owner_user_id": None}
assert ( assert (
client.post("/claude/skills/install", json={"prompt": "x"}, headers=lowpriv).status_code client.post("/claude/skills/install", json={"prompt": "x"}, headers=lowpriv).status_code