diff --git a/.env.example b/.env.example index ab22cfe..91524d3 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,32 @@ # Postgres: postgresql+psycopg://user:pass@host:5432/handler 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 +# ---- 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. # Falls back to AUTH_TOKEN if unset. # SHARED_CONTEXT_WRITE_TOKEN= diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a25f23..75b4a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,52 @@ since its last release: kind filters and expandable notes showing body, tags, and links; authoring stays on 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)) - **`harness` on model backends** (`claude` | `pi`, default `claude`). A backend row can diff --git a/README.md b/README.md index 499e3cd..d6e6087 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,12 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example) | Variable | Purpose | Default | |---|---|---| | `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` | -| `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 | | `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 | @@ -204,6 +207,56 @@ docker compose run --rm control handler list 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 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 -All routes require `Authorization: Bearer `. `GET /health` is unauthenticated. +All routes require `Authorization: Bearer ` — 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 | |---|---| +| `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/:p/agents` · `POST …` | List / register agents (project-scoped) | | `GET /projects/:p/agents/:name/checkmark` | The agent's current-state checkmark | diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7e25881..05d3f36 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -392,6 +392,21 @@ a:hover { 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 { border-radius: var(--radius-md); diff --git a/frontend/app/reset/page.tsx b/frontend/app/reset/page.tsx new file mode 100644 index 0000000..9c29e1d --- /dev/null +++ b/frontend/app/reset/page.tsx @@ -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("/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 ( +
+
+
+ + Claude Monitor +
+ {token ? ( + <> +

+ Choose a password for your account. The link you followed is one-shot — once + set, sign in with your email and this password. +

+ setPassword(e.target.value)} + minLength={8} + autoFocus + required + /> + setConfirm(e.target.value)} + minLength={8} + required + /> + {error && ( +

+ {error} +

+ )} + + + ) : ( +

+ This page needs a reset link (…/reset?token=…). Ask an admin for one, or use + “Forgot password?” on the sign-in page. +

+ )} +
+
+ ); +} + +export default function ResetPage() { + // useSearchParams requires a Suspense boundary under the static export. + return ( + + + + ); +} diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx new file mode 100644 index 0000000..9326453 --- /dev/null +++ b/frontend/app/users/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/components/AppFrame.tsx b/frontend/components/AppFrame.tsx index 10c99a4..aa8bfa7 100644 --- a/frontend/components/AppFrame.tsx +++ b/frontend/components/AppFrame.tsx @@ -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 - * every byte of data is fetched by the browser from the authed API after the token is - * supplied. A 401 from any call clears the token and re-prompts with an error. */ + * every byte of data is fetched by the browser from the authed API after sign-in. The + * 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"; import { useCallback, useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { DashboardProvider } from "@/components/store"; import { Shell } from "@/components/Shell"; -import { TokenGate } from "@/components/TokenGate"; +import { AuthGate } from "@/components/AuthGate"; import { sectionFromPath } from "@/lib/nav"; +import { type SessionResponse } from "@/lib/api"; const TOKEN_KEY = "handler_token"; @@ -30,7 +33,22 @@ export function AppFrame({ children }: { children: React.ReactNode }) { setToken(t); }, []); + const onSession = useCallback( + (s: SessionResponse) => { + saveToken(s.token); + }, + [saveToken], + ); + 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); setToken(null); }, []); @@ -38,11 +56,17 @@ export function AppFrame({ children }: { children: React.ReactNode }) { const onUnauthorized = useCallback(() => { window.localStorage.removeItem(TOKEN_KEY); 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) { - return ; + return ; } return ( diff --git a/frontend/components/AuthGate.tsx b/frontend/components/AuthGate.tsx new file mode 100644 index 0000000..cc765f7 --- /dev/null +++ b/frontend/components/AuthGate.tsx @@ -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("loading"); + const [status, setStatus] = useState(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("/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("/auth/setup", { email, password })); + } else if (mode === "login") { + onSession(await authApi("/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 ( +
+
+
+ + Claude Monitor +
+

+ Loading… +

+
+
+ ); + } + + 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 ( +
+
+
+ + Claude Monitor +
+

+ {heading} +

+ + {mode !== "token" && ( + setEmail(e.target.value)} + autoFocus + required + /> + )} + {(mode === "login" || mode === "setup") && ( + setPassword(e.target.value)} + minLength={8} + required + /> + )} + {mode === "setup" && ( + setConfirm(e.target.value)} + minLength={8} + required + /> + )} + {mode === "token" && ( + setRawToken(e.target.value)} + autoFocus + /> + )} + + {formError && ( +

+ {formError} +

+ )} + {message && ( +

+ {message} +

+ )} + + + +
+ {mode === "login" && ( + + )} + {(mode === "forgot" || mode === "token") && status?.initialized !== false && ( + + )} + {mode !== "token" && ( + + )} + {mode === "token" && status?.initialized === false && ( + + )} +
+
+
+ ); +} diff --git a/frontend/components/Shell.tsx b/frontend/components/Shell.tsx index d0399d3..2c6b00b 100644 --- a/frontend/components/Shell.tsx +++ b/frontend/components/Shell.tsx @@ -35,6 +35,7 @@ const BADGES: Partial number; accent?: (s // Draw the eye to it until Claude is logged in on the host this session. accent: (s) => s.claudeLogin.status !== "done", }, + users: { count: (s) => s.users.length }, }; export function Shell({ onSignOut, children }: { onSignOut: () => void; children: React.ReactNode }) { @@ -55,7 +56,7 @@ export function Shell({ onSignOut, children }: { onSignOut: () => void; children Claude Monitor - {NAV_ROUTES.map((n) => { + {NAV_ROUTES.filter((n) => n.key !== "users" || s.me?.is_admin).map((n) => { const badge = BADGES[n.key]; const c = badge?.count(s) ?? 0; const isAccent = badge?.accent?.(s) ?? false; @@ -74,11 +75,30 @@ export function Shell({ onSignOut, children }: { onSignOut: () => void; children })}
+ {s.me && ( +
+ + {s.me.kind === "token" ? "API token" : s.me.email} + {s.me.is_admin ? " · admin" : ""} + +
+ )} -
diff --git a/frontend/components/TokenGate.tsx b/frontend/components/TokenGate.tsx deleted file mode 100644 index 52a18f1..0000000 --- a/frontend/components/TokenGate.tsx +++ /dev/null @@ -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 ( -
-
-
- - Claude Monitor -
-

- Paste your API token to continue. Management actions require the admin token; read-only - views work with the plain auth token. -

- setValue(e.target.value)} - autoFocus - /> - {error && ( -

- {error} -

- )} - -
-
- ); -} diff --git a/frontend/components/sections/UsersSection.tsx b/frontend/components/sections/UsersSection.tsx new file mode 100644 index 0000000..fe57ff2 --- /dev/null +++ b/frontend/components/sections/UsersSection.tsx @@ -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 ( + <> +
+
Users
+
+ Accounts for this Handler. Each user's projects, skills, and tools are + theirs alone; shared (unowned) resources are visible to everyone and managed by + admins. +
+
+
+
+ setEmail(e.target.value)} + style={{ maxWidth: 320 }} + required + /> + + +
+ + {lastLink && ( +
+ + One-shot set-password link for {lastLink.email} (share it over + a channel you trust; it expires): + + + {lastLink.url} + +
+ )} + + {s.users.length === 0 ? ( +
No users loaded (admin access required).
+ ) : ( +
+ + + + + + + + + + + + {s.users.map((u) => ( + + + + + + + + ))} + +
EmailRoleStatusCreatedActions
+ {u.email} + {isSelf(u.id) ? (you) : null} + {u.is_admin ? "admin" : "user"} + {u.disabled + ? "disabled" + : u.has_password + ? "active" + : "invited — awaiting password"} + {fmtFull(u.created_at)} +
+ + + + +
+
+
+ )} +
+ + ); +} diff --git a/frontend/components/store.tsx b/frontend/components/store.tsx index c493202..02018a7 100644 --- a/frontend/components/store.tsx +++ b/frontend/components/store.tsx @@ -29,11 +29,15 @@ import { type Command, type Host, type LogEntry, + type Me, type MemoryGraph, type NoteKind, type Project, + type ResetLink, type Schedule, type SharedContext, + type User, + type UserCreated, } from "@/lib/api"; export type Section = @@ -46,7 +50,8 @@ export type Section = | "activity" | "shared" | "memory" - | "claude"; + | "claude" + | "users"; /* The claude web-login flow, driven through the login_start / login_submit commands. * idle → starting → awaiting (have URL) → submitting → done | error */ @@ -108,6 +113,17 @@ interface StoreValue { lastError: string; 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; + updateUser: (id: number, b: { is_admin?: boolean; disabled?: boolean }) => Promise; + deleteUser: (id: number) => Promise; + mintResetLink: (id: number) => Promise; + refresh: () => void; // actions @@ -338,6 +354,8 @@ export function DashboardProvider({ const [claudePlugins, setClaudePlugins] = useState([]); const [claudePermissions, setClaudePermissions] = useState(null); const [claudeModels, setClaudeModels] = useState([]); + const [me, setMe] = useState(null); + const [users, setUsers] = useState([]); // Keep polling loop reading fresh values without re-subscribing every render. const sectionRef = useRef(section); @@ -494,6 +512,23 @@ export function DashboardProvider({ } }, []); + const loadMe = useCallback(async () => { + try { + setMe(await clientRef.current.api("/auth/me")); + } catch (e) { + if (!(e instanceof AuthError)) setMe(null); + } + }, []); + + const loadUsers = useCallback(async () => { + try { + setUsers(await clientRef.current.api("/auth/users")); + } catch { + // Non-admins get a 403 here; the section is hidden for them anyway. + setUsers([]); + } + }, []); + const loadClaude = useCallback(async () => { try { const [skills, connectors, plugins, permissions] = await Promise.all([ @@ -538,10 +573,15 @@ export function DashboardProvider({ if (s === "shared") await loadShared(); if (s === "memory") await loadMemory(); 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 // active section) up front, so the Runs inbox is filled without waiting a poll interval. + useEffect(() => { + void loadMe(); + }, [loadMe]); + useEffect(() => { let alive = true; (async () => { @@ -570,8 +610,9 @@ export function DashboardProvider({ if (s === "shared") void loadShared(); if (s === "memory") void loadMemory(); 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( @@ -1400,6 +1441,84 @@ export function DashboardProvider({ [memoryWrite], ); + // ---- user accounts (admin management; direct writes like the Claude pages) ---- + const createUser = useCallback( + async (email: string, isAdmin: boolean): Promise => { + try { + const created = await clientRef.current.api("/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 => { + try { + const link = await clientRef.current.api(`/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 = { section, setSection, @@ -1424,6 +1543,12 @@ export function DashboardProvider({ cmd, lastError, loading, + me, + users, + createUser, + updateUser, + deleteUser, + mintResetLink, refresh, spawnAgent, killAgent, diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 3a7a006..59a8270 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -15,6 +15,8 @@ export interface Project { root_dir: string; git_remote?: 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; /* Present on the registration response in git-server mode: the enqueued clone. */ sync_command_id?: number | null; @@ -156,6 +158,7 @@ export interface ClaudeSkill { /* Relative paths of auxiliary files captured by an install-from-prompt import * (references/, scripts/, …); synced alongside SKILL.md, read-only here. */ files: string[]; + owner_user_id?: number | null; created_at: string; updated_at: string; } @@ -172,6 +175,7 @@ export interface ClaudeConnector { url?: string | null; headers?: Record | null; enabled: boolean; + owner_user_id?: number | null; created_at: string; } @@ -181,6 +185,7 @@ export interface ClaudePlugin { marketplace: string; marketplace_repo: string; enabled: boolean; + owner_user_id?: number | null; created_at: string; } @@ -199,6 +204,7 @@ export interface ClaudeModel { env?: Record | null; enabled: boolean; has_api_key: boolean; + owner_user_id?: number | null; created_at: string; } @@ -250,6 +256,71 @@ export interface SharedContext { 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(path: string, body?: unknown): Promise { + 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 * quiet while the app re-prompts for a token. */ export class AuthError extends Error { diff --git a/frontend/lib/nav.ts b/frontend/lib/nav.ts index ec1e137..78c116e 100644 --- a/frontend/lib/nav.ts +++ b/frontend/lib/nav.ts @@ -20,6 +20,8 @@ export const NAV_ROUTES: NavRoute[] = [ { key: "shared", href: "/shared", label: "Shared" }, { key: "memory", href: "/memory", label: "Memory" }, { 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 diff --git a/src/handler/api/app.py b/src/handler/api/app.py index 7c279af..deb7748 100644 --- a/src/handler/api/app.py +++ b/src/handler/api/app.py @@ -17,6 +17,7 @@ from ..config import get_settings from .routes import ( agents, approvals, + auth, claude, commands, hosts, @@ -44,6 +45,7 @@ def create_app() -> FastAPI: def health() -> dict: return {"status": "ok"} + app.include_router(auth.router) app.include_router(projects.router) app.include_router(agents.router) app.include_router(interaction.router) diff --git a/src/handler/api/deps.py b/src/handler/api/deps.py index c2b33d2..66f935e 100644 --- a/src/handler/api/deps.py +++ b/src/handler/api/deps.py @@ -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 -writes may require a separate higher-trust token (README 3.4), falling back to the -global token when unset. +Two kinds of callers hold a bearer token: + +- **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 import secrets from collections.abc import Iterator +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import Connection +from .. import authn from ..config import Settings, get_settings +from ..db import repository as repo from ..db.engine import connection _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]: with connection() as conn: @@ -31,52 +50,116 @@ def _check(provided: str | None, expected: str) -> bool: 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), 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 - # The shared-context write and admin tokens are higher-trust, so they also grant - # normal access; a single request carries one bearer, and it should never be rejected - # for being the more privileged one. - valid = ( - _check(token, settings.auth_token) - or _check(token, settings.effective_shared_write_token) - or _check(token, settings.effective_admin_token) + if token is None: + raise _unauthorized() + + # Legacy env tokens first (cheap constant-time compares). Each capability is + # checked independently so the historical fallbacks hold exactly: with ADMIN_TOKEN + # unset the plain token comes out admin, while a dedicated admin token does *not* + # 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( - creds: HTTPAuthorizationCredentials | None = Depends(_bearer), - settings: Settings = Depends(get_settings), -) -> None: - """Gate for shared_context writes — the one table every project implicitly trusts.""" - token = creds.credentials if creds else None - if not _check(token, settings.effective_shared_write_token): +def _unauthorized() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid or missing bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +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( 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"}, ) + return actor -def require_admin( - creds: HTTPAuthorizationCredentials | None = Depends(_bearer), - settings: Settings = Depends(get_settings), -) -> None: - """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): +def require_admin(actor: Actor = Depends(get_actor)) -> Actor: + """Gate for the global control surface: git servers, the Claude account login, + permission overrides, and user management. Admin users or the admin token.""" + if not actor.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="this action requires the admin token", + detail="this action requires an admin", headers={"WWW-Authenticate": "Bearer"}, ) + return actor diff --git a/src/handler/api/routes/agents.py b/src/handler/api/routes/agents.py index 2c6d4a1..d477306 100644 --- a/src/handler/api/routes/agents.py +++ b/src/handler/api/routes/agents.py @@ -13,7 +13,7 @@ from sqlalchemy import Connection from sqlalchemy.exc import IntegrityError 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 ( AgentEventOut, AgentIn, @@ -23,7 +23,7 @@ from ..schemas import ( LogEntryOut, SpawnIn, ) -from .common import resolve_agent +from .common import resolve_agent, resolve_project router = APIRouter( 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]) -def list_agents(project: str, conn: Connection = Depends(db_conn)) -> list[dict]: - _require_project(conn, project) +def list_agents( + 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) @router.post("", response_model=AgentOut, status_code=status.HTTP_201_CREATED) -def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn)) -> dict: - _require_project(conn, project) +def create_agent( + 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: raise HTTPException( status.HTTP_409_CONFLICT, @@ -68,11 +72,15 @@ def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn "/spawn", response_model=CommandOut, 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.""" - _require_project(conn, project) + resolve_project(conn, project, actor, edit=True) if repo.get_agent_by_name(conn, project, body.name) is not None: raise HTTPException( 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: # 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. + # Ownership counts too: another user's private backend is "not found" here. 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( 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, agent_name=body.name, 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", response_model=CommandOut, status_code=status.HTTP_202_ACCEPTED, - dependencies=[Depends(require_admin)], ) -def enqueue_kill(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: - resolve_agent(conn, project, name) +def enqueue_kill( + 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( - 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)]) -def delete_agent(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: +@router.delete("/{name}") +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).""" - resolve_agent(conn, project, name) + resolve_agent(conn, project, name, actor, edit=True) repo.delete_agent(conn, project, name) return {"deleted": name} @router.get("/{name}/checkmark", response_model=CheckmarkOut) -def get_checkmark(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: - agent = resolve_agent(conn, project, name) +def get_checkmark( + 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"]) if checkmark is None: raise HTTPException( @@ -149,6 +172,7 @@ def get_events( name: str, after_id: int = Query(0, ge=0), limit: int = Query(200, ge=1, le=1000), + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> list[dict]: """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 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) @@ -166,7 +190,8 @@ def get_log( name: str, limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> 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) diff --git a/src/handler/api/routes/approvals.py b/src/handler/api/routes/approvals.py index 5aa607b..d3dc964 100644 --- a/src/handler/api/routes/approvals.py +++ b/src/handler/api/routes/approvals.py @@ -8,12 +8,13 @@ treats as a genuine second party (satisfying the "no self-approval" rule). from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Query, status from sqlalchemy import Connection 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 .common import resolve_project router = APIRouter( 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]) def list_approvals( project: str, branch: str | None = Query(None), + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> list[dict]: - _require_project(conn, project) + resolve_project(conn, project, actor) return repo.list_approvals(conn, project, branch=branch) @@ -41,12 +38,14 @@ def list_approvals( "", response_model=CommandOut, status_code=status.HTTP_202_ACCEPTED, - dependencies=[Depends(require_admin)], ) 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: - _require_project(conn, project) + resolve_project(conn, project, actor, edit=True) payload = { "branch": body.branch, "sha": body.sha, @@ -61,5 +60,5 @@ def enqueue_approval( project_id=project, agent_name=body.agent_name, payload={k: v for k, v in payload.items() if v is not None}, - requested_by="operator:web", + requested_by=actor.label, ) diff --git a/src/handler/api/routes/auth.py b/src/handler/api/routes/auth.py new file mode 100644 index 0000000..75156b4 --- /dev/null +++ b/src/handler/api/routes/auth.py @@ -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} diff --git a/src/handler/api/routes/claude.py b/src/handler/api/routes/claude.py index c16b78b..07ecf1e 100644 --- a/src/handler/api/routes/claude.py +++ b/src/handler/api/routes/claude.py @@ -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 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 -is allowed to do). The login flow stays under ``/login`` — it needs the worker's tmux. +Skills, connectors, plugins, and model backends are per-user resources: everyone sees +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 @@ -19,7 +22,7 @@ from sqlalchemy import Connection from ... import secretstore from ...config import get_settings 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 ( ClaudeConnectorIn, ClaudeConnectorOut, @@ -42,12 +45,32 @@ from ..schemas import ( 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 --------------------------------------------------------------------------- -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) - 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") return skill @@ -60,21 +83,35 @@ def _skill_out(conn: Connection, row: dict) -> dict: @router.get("/skills", response_model=list[ClaudeSkillOut]) -def list_skills(conn: Connection = Depends(db_conn)) -> list[dict]: - return [_skill_out(conn, s) for s in repo.list_claude_skills(conn)] +def list_skills( + 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( "/skills", response_model=ClaudeSkillOut, 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: raise HTTPException(status.HTTP_409_CONFLICT, detail=f"skill '{body.name}' exists") 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", response_model=CommandOut, 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 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 - 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( - 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( - "/skills/{skill_id}", response_model=ClaudeSkillOut, dependencies=[Depends(require_admin)] -) +@router.patch("/skills/{skill_id}", response_model=ClaudeSkillOut) 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: - _skill_or_404(conn, skill_id) + _require_edit(actor, _skill_or_404(conn, skill_id, actor), "skill") fields = body.model_dump(exclude_unset=True) if "name" in fields: 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)) -@router.delete("/skills/{skill_id}", dependencies=[Depends(require_admin)]) -def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict: - skill = _skill_or_404(conn, skill_id) +@router.delete("/skills/{skill_id}") +def delete_skill( + 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) return {"deleted": skill["name"]} @@ -121,9 +172,9 @@ def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict: # ---- 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) - if connector is None: + if connector is None or not actor.can_view(connector.get("owner_user_id")): raise HTTPException( 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]) -def list_connectors(conn: Connection = Depends(db_conn)) -> list[dict]: - return repo.list_claude_connectors(conn) +def list_connectors( + 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( "/connectors", response_model=ClaudeConnectorOut, 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: raise HTTPException(status.HTTP_409_CONFLICT, detail=f"connector '{body.name}' exists") return repo.create_claude_connector( @@ -154,18 +211,22 @@ def create_connector(body: ClaudeConnectorIn, conn: Connection = Depends(db_conn url=body.url, headers=body.headers, enabled=body.enabled, + owner_user_id=actor.user_id, ) @router.patch( "/connectors/{connector_id}", response_model=ClaudeConnectorOut, - dependencies=[Depends(require_admin)], ) 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: - 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) if "name" in fields: 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) -@router.delete("/connectors/{connector_id}", dependencies=[Depends(require_admin)]) -def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) -> dict: - connector = _connector_or_404(conn, connector_id) +@router.delete("/connectors/{connector_id}") +def delete_connector( + 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) return {"deleted": connector["name"]} @@ -199,42 +265,55 @@ def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) -> # ---- 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) - 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") return plugin @router.get("/plugins", response_model=list[ClaudePluginOut]) -def list_plugins(conn: Connection = Depends(db_conn)) -> list[dict]: - return repo.list_claude_plugins(conn) +def list_plugins( + 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( "/plugins", response_model=ClaudePluginOut, 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: raise HTTPException( status.HTTP_409_CONFLICT, detail=f"plugin '{body.name}@{body.marketplace}' exists", ) 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( - "/plugins/{plugin_id}", response_model=ClaudePluginOut, dependencies=[Depends(require_admin)] -) +@router.patch("/plugins/{plugin_id}", response_model=ClaudePluginOut) 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: - 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) if "name" in fields or "marketplace" in fields: merged = {**current, **fields} @@ -247,9 +326,14 @@ def update_plugin( return repo.update_claude_plugin(conn, plugin_id, **fields) -@router.delete("/plugins/{plugin_id}", dependencies=[Depends(require_admin)]) -def delete_plugin(plugin_id: int, conn: Connection = Depends(db_conn)) -> dict: - plugin = _plugin_or_404(conn, plugin_id) +@router.delete("/plugins/{plugin_id}") +def delete_plugin( + 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) 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. -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) - 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") return row @@ -282,17 +366,25 @@ def _encrypt_key_or_400(value: str) -> str: @router.get("/models", response_model=list[ClaudeModelOut]) -def list_models(conn: Connection = Depends(db_conn)) -> list[dict]: - return [_model_out(m) for m in repo.list_claude_models(conn)] +def list_models( + 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( "/models", response_model=ClaudeModelOut, 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: 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 @@ -307,17 +399,19 @@ def create_model(body: ClaudeModelIn, conn: Connection = Depends(db_conn)) -> di harness=body.harness, env=body.env, enabled=body.enabled, + owner_user_id=actor.user_id, ) ) -@router.patch( - "/models/{model_id}", response_model=ClaudeModelOut, dependencies=[Depends(require_admin)] -) +@router.patch("/models/{model_id}", response_model=ClaudeModelOut) 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: - _model_or_404(conn, model_id) + _require_edit(actor, _model_or_404(conn, model_id, actor), "model") fields = body.model_dump(exclude_unset=True) if "name" in fields: 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)) -@router.delete("/models/{model_id}", dependencies=[Depends(require_admin)]) -def delete_model(model_id: int, conn: Connection = Depends(db_conn)) -> dict: - row = _model_or_404(conn, model_id) +@router.delete("/models/{model_id}") +def delete_model( + 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) return {"deleted": row["name"]} diff --git a/src/handler/api/routes/commands.py b/src/handler/api/routes/commands.py index 697f2c9..0ea4977 100644 --- a/src/handler/api/routes/commands.py +++ b/src/handler/api/routes/commands.py @@ -12,8 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Connection 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 .common import visible_project_ids router = APIRouter(tags=["commands"], dependencies=[Depends(require_auth)]) @@ -23,14 +24,32 @@ def list_commands( project: str | None = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> 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) -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) + 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: raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"command {command_id} not found") return command @@ -40,8 +59,9 @@ def get_command(command_id: int, conn: Connection = Depends(db_conn)) -> dict: "/poll-ci", response_model=CommandOut, 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).""" - return repo.enqueue_command(conn, "poll_ci", requested_by="operator:web") + return repo.enqueue_command(conn, "poll_ci", requested_by=actor.label) diff --git a/src/handler/api/routes/common.py b/src/handler/api/routes/common.py index 75a3187..44996cd 100644 --- a/src/handler/api/routes/common.py +++ b/src/handler/api/routes/common.py @@ -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 @@ -6,16 +14,41 @@ from fastapi import HTTPException, status from sqlalchemy import Connection from ...db import repository as repo +from ..deps import Actor -def resolve_agent(conn: Connection, project: str, name: str) -> dict: - """Fetch an agent by ``(project, name)`` or 404. +def resolve_project( + 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. - """ - if repo.get_project(conn, project) is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found") + +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 actor.sees_all: + 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) if agent is None: raise HTTPException( diff --git a/src/handler/api/routes/interaction.py b/src/handler/api/routes/interaction.py index 922d2ce..4df8369 100644 --- a/src/handler/api/routes/interaction.py +++ b/src/handler/api/routes/interaction.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import Connection 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 .common import resolve_agent @@ -30,9 +30,10 @@ def answer( project: str, name: str, body: AnswerIn, + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> AnswerOut: - agent = resolve_agent(conn, project, name) + agent = resolve_agent(conn, project, name, actor) if body.log_entry_id is not None: log_entry_id = body.log_entry_id @@ -58,15 +59,15 @@ def answer( "/resume", response_model=CommandOut, status_code=status.HTTP_202_ACCEPTED, - dependencies=[Depends(require_admin)], ) def resume( project: str, name: str, body: ResumeIn, + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> 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. answer_text = body.answer @@ -92,5 +93,5 @@ def resume( project_id=project, agent_name=name, payload={"answer": answer_text}, - requested_by="operator:web", + requested_by=actor.label, ) diff --git a/src/handler/api/routes/memory.py b/src/handler/api/routes/memory.py index a9130af..558d907 100644 --- a/src/handler/api/routes/memory.py +++ b/src/handler/api/routes/memory.py @@ -3,8 +3,10 @@ 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 — 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 -feed every future agent's context, so authoring them is a control-surface action). +management pages. Visibility follows the note's project (global notes are visible to +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 @@ -13,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Connection 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 ( MemoryGraphOut, MemoryLinkIn, @@ -22,22 +24,37 @@ from ..schemas import ( MemoryNoteOut, MemoryNoteUpdateIn, ) +from .common import resolve_project, visible_project_ids 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) + 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: raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"note {note_id} not found") return note -def _project_or_400(conn: Connection, project_id: str | None) -> None: - if project_id is not None and repo.get_project(conn, project_id) is None: +def _require_note_edit(conn: Connection, note_project_id: str | None, actor: Actor) -> 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( - 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]) @@ -46,36 +63,52 @@ def list_notes( q: str | None = Query(None), limit: int = Query(200, ge=1, le=1000), offset: int = Query(0, ge=0), + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> list[dict]: """Notes in scope, newest first; ``q`` switches to substring search (all terms).""" + visible = visible_project_ids(conn, actor) if q: - return repo.search_memory_notes(conn, q, project_id=project_id, limit=limit) - return repo.list_memory_notes(conn, project_id=project_id, limit=limit, offset=offset) + return repo.search_memory_notes( + 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) def graph( project_id: str | None = Query(None), + actor: Actor = Depends(get_actor), conn: Connection = Depends(db_conn), ) -> dict: """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) -def get_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict: - return _note_or_404(conn, note_id) +def get_note( + note_id: int, + actor: Actor = Depends(get_actor), + conn: Connection = Depends(db_conn), +) -> dict: + return _note_or_404(conn, note_id, actor) @router.post( "/notes", response_model=MemoryNoteOut, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_admin)], ) -def create_note(body: MemoryNoteIn, conn: Connection = Depends(db_conn)) -> dict: - _project_or_400(conn, body.project_id) +def create_note( + 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( conn, title=body.title, @@ -87,22 +120,30 @@ def create_note(body: MemoryNoteIn, conn: Connection = Depends(db_conn)) -> dict ) -@router.patch( - "/notes/{note_id}", response_model=MemoryNoteOut, dependencies=[Depends(require_admin)] -) +@router.patch("/notes/{note_id}", response_model=MemoryNoteOut) 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: - _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) - if "project_id" in fields: - _project_or_400(conn, fields["project_id"]) + if "project_id" in fields and fields["project_id"] != note.get("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) -@router.delete("/notes/{note_id}", dependencies=[Depends(require_admin)]) -def delete_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict: - note = _note_or_404(conn, note_id) +@router.delete("/notes/{note_id}") +def delete_note( + 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) return {"deleted": note["id"]} @@ -111,20 +152,35 @@ def delete_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict: "/links", response_model=MemoryLinkOut, 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: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="a note cannot link to itself") - _note_or_404(conn, body.src_note_id) - _note_or_404(conn, body.dst_note_id) + src = _note_or_404(conn, body.src_note_id, actor) + 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( conn, body.src_note_id, body.dst_note_id, relation=body.relation, agent_id=None ) -@router.delete("/links/{link_id}", dependencies=[Depends(require_admin)]) -def delete_link(link_id: int, conn: Connection = Depends(db_conn)) -> dict: - if not repo.delete_memory_link(conn, link_id): +@router.delete("/links/{link_id}") +def delete_link( + 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") + 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} diff --git a/src/handler/api/routes/projects.py b/src/handler/api/routes/projects.py index 8689adb..4ed14e6 100644 --- a/src/handler/api/routes/projects.py +++ b/src/handler/api/routes/projects.py @@ -1,8 +1,9 @@ """Project CRUD + project-scoped control actions. -Reads and row registration take the normal token; edits/deletes and the enqueue actions -(forge-init, poll-ci) take the admin token. The agent *process* work (spawn/kill) lives in -``agents.py``; here we cover the project itself and the two project-wide control actions. +Every route resolves through the ownership rules in ``routes.common``: users see shared +projects plus their own, admins (and legacy tokens) see everything, and mutations plus +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 @@ -16,27 +17,27 @@ from sqlalchemy.exc import IntegrityError from ...config import get_settings 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 .common import resolve_project 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]) -def list_projects(conn: Connection = Depends(db_conn)) -> list[dict]: - return repo.list_projects(conn) +def list_projects( + 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) -def get_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: - return _get_or_404(conn, project_id) +def get_project( + 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: @@ -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) -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: project_id, root_dir, git_remote = _from_git_server(body, conn) else: @@ -88,6 +93,8 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict root_dir=root_dir, git_remote=git_remote, 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 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 if git_remote: 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"] @@ -107,7 +114,7 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict mise_init_command_id = None if body.init_mise and git_remote: 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"] @@ -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( - 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: - _get_or_404(conn, project_id) + resolve_project(conn, project_id, actor, edit=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) -@router.delete("/{project_id}", dependencies=[Depends(require_admin)]) -def delete_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: - _get_or_404(conn, project_id) +@router.delete("/{project_id}") +def delete_project( + 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) return {"deleted": project_id} @@ -138,18 +157,20 @@ def delete_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict "/{project_id}/forge-init", response_model=CommandOut, status_code=status.HTTP_202_ACCEPTED, - dependencies=[Depends(require_admin)], ) 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: - _get_or_404(conn, project_id) + resolve_project(conn, project_id, actor, edit=True) return repo.enqueue_command( conn, "forge_init", project_id=project_id, payload={"no_commit": no_commit}, - requested_by="operator:web", + requested_by=actor.label, ) @@ -157,18 +178,21 @@ def enqueue_forge_init( "/{project_id}/sync", response_model=CommandOut, 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).""" - project = _get_or_404(conn, project_id) + project = resolve_project(conn, project_id, actor, edit=True) if not project.get("git_remote"): raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=f"project '{project_id}' has no git_remote to sync from", ) 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", response_model=CommandOut, status_code=status.HTTP_202_ACCEPTED, - dependencies=[Depends(require_admin)], ) -def enqueue_poll_ci(project_id: str, conn: Connection = Depends(db_conn)) -> dict: - _get_or_404(conn, project_id) +def enqueue_poll_ci( + 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( - conn, "poll_ci", project_id=project_id, requested_by="operator:web" + conn, "poll_ci", project_id=project_id, requested_by=actor.label ) diff --git a/src/handler/api/routes/schedules.py b/src/handler/api/routes/schedules.py index 8b430e4..67e762b 100644 --- a/src/handler/api/routes/schedules.py +++ b/src/handler/api/routes/schedules.py @@ -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 the file in the repo carries the state between runs. -Reads take the normal token; writes take the admin token (a schedule ultimately runs -``claude`` in the control container). +Schedules belong to their project, so visibility and edit rights follow the project's +owner (a schedule ultimately runs ``claude`` against that project). """ from __future__ import annotations @@ -18,8 +18,9 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import Connection 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 .common import resolve_project, visible_project_ids router = APIRouter(tags=["schedules"], dependencies=[Depends(require_auth)]) @@ -33,13 +34,14 @@ def _schedule_or_404(conn: Connection, schedule_id: int) -> dict: 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 - 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: return 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") if not model["enabled"]: raise HTTPException( @@ -48,12 +50,24 @@ def _check_model(conn: Connection, model_id: int | None) -> None: @router.get("/schedules", response_model=list[ScheduleOut]) -def list_all_schedules(conn: Connection = Depends(db_conn)) -> list[dict]: - return repo.list_schedules(conn) +def list_all_schedules( + 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]) -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) @@ -61,16 +75,15 @@ def list_project_schedules(project_id: str, conn: Connection = Depends(db_conn)) "/projects/{project_id}/schedules", response_model=ScheduleOut, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_admin)], ) 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: - if repo.get_project(conn, project_id) is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"project '{project_id}' not found" - ) - _check_model(conn, body.model_id) + resolve_project(conn, project_id, actor, edit=True) + _check_model(conn, body.model_id, actor) # 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. return repo.create_schedule( @@ -91,20 +104,28 @@ def create_schedule( @router.patch( "/schedules/{schedule_id}", response_model=ScheduleOut, - dependencies=[Depends(require_admin)], ) 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: - _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) 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) -@router.delete("/schedules/{schedule_id}", dependencies=[Depends(require_admin)]) -def delete_schedule(schedule_id: int, conn: Connection = Depends(db_conn)) -> dict: - _schedule_or_404(conn, schedule_id) +@router.delete("/schedules/{schedule_id}") +def delete_schedule( + 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) return {"deleted": schedule_id} diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py index f8b9594..4493df5 100644 --- a/src/handler/api/schemas.py +++ b/src/handler/api/schemas.py @@ -90,6 +90,8 @@ class ProjectUpdateIn(BaseModel): root_dir: str | None = None git_remote: 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") @classmethod @@ -104,6 +106,8 @@ class ProjectOut(BaseModel): root_dir: str git_remote: 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 @@ -409,6 +413,7 @@ class ClaudeSkillOut(BaseModel): # Relative paths of auxiliary files (references/, scripts/, …) captured by the # install-from-prompt import; synced alongside SKILL.md, read-only over the API. files: list[str] = Field(default_factory=list) + owner_user_id: int | None = None created_at: datetime updated_at: datetime @@ -472,6 +477,7 @@ class ClaudeConnectorOut(BaseModel): url: str | None = None headers: dict[str, str] | None = None enabled: bool + owner_user_id: int | None = None created_at: datetime @@ -521,6 +527,7 @@ class ClaudePluginOut(BaseModel): marketplace: str marketplace_repo: str enabled: bool + owner_user_id: int | None = None created_at: datetime @@ -587,6 +594,7 @@ class ClaudeModelOut(BaseModel): enabled: bool # The key never leaves the server; this says whether one is stored. has_api_key: bool = False + owner_user_id: int | None = None created_at: datetime @@ -680,3 +688,107 @@ class MemoryGraphOut(BaseModel): notes: list[MemoryNoteOut] 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 diff --git a/src/handler/authn.py b/src/handler/authn.py new file mode 100644 index 0000000..47539be --- /dev/null +++ b/src/handler/authn.py @@ -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() diff --git a/src/handler/config.py b/src/handler/config.py index 44f5baf..69aa09f 100644 --- a/src/handler/config.py +++ b/src/handler/config.py @@ -19,7 +19,10 @@ class Settings(BaseSettings): # "is it sqlite" except db.upsert. 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 = "" # 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. 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. webhook_url: str | None = None diff --git a/src/handler/control/claude_gen.py b/src/handler/control/claude_gen.py index bfaa027..c3ea9b5 100644 --- a/src/handler/control/claude_gen.py +++ b/src/handler/control/claude_gen.py @@ -137,8 +137,8 @@ def sync_user_skills(skills: list[dict], home: str | None = None) -> list[str]: return written -def _load_skills(conn: Connection) -> list[dict]: - skills = repo.list_claude_skills(conn, enabled_only=True) +def _load_skills(conn: Connection, visible_to) -> list[dict]: + skills = repo.list_claude_skills(conn, enabled_only=True, visible_to=visible_to) return [ { **s, @@ -150,15 +150,24 @@ def _load_skills(conn: Connection) -> list[dict]: ] -def apply(working_dir: str, conn: Connection | None = None) -> dict: - """Apply the whole web-managed config for one launch; returns a small summary.""" +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. + + ``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: with connection() as c: - connectors = repo.list_claude_connectors(c, enabled_only=True) - skills = _load_skills(c) + connectors = repo.list_claude_connectors(c, enabled_only=True, visible_to=visible_to) + skills = _load_skills(c, visible_to) else: - connectors = repo.list_claude_connectors(conn, enabled_only=True) - skills = _load_skills(conn) + connectors = repo.list_claude_connectors(conn, enabled_only=True, visible_to=visible_to) + skills = _load_skills(conn, visible_to) mcp_path = write_mcp_config(working_dir, [memory_server_connector()] + connectors) written = sync_user_skills(skills) return {"mcp_config": mcp_path, "skills_written": len(written)} diff --git a/src/handler/control/skill_install.py b/src/handler/control/skill_install.py index 351c1eb..4b4ddc1 100644 --- a/src/handler/control/skill_install.py +++ b/src/handler/control/skill_install.py @@ -170,10 +170,13 @@ def _collect_skill(skill_dir: str) -> tuple[dict[str, str], dict[str, str], list 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 ``//SKILL.md`` as a managed skill row (matched by 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] = [] for entry in sorted(os.listdir(staging_dir)): 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" existing = repo.get_claude_skill_by_name(conn, name) 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" else: row = repo.update_claude_skill( @@ -205,11 +210,12 @@ def import_staged(staging_dir: str, conn: Connection) -> list[dict]: 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. Returns ``{"skills": [...], "summary": }``; 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() if not prompt: @@ -221,7 +227,7 @@ def run(prompt: str) -> dict: output = _run_claude(_WRAPPER.format(prompt=prompt), staging, settings_path) os.remove(settings_path) # never importable, but keep the scan surface clean with connection() as conn: - skills = import_staged(staging, conn) + skills = import_staged(staging, conn, owner_user_id=owner_user_id) if not skills: raise InstallError( "the install run finished but no /SKILL.md landed in the staging " diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index 872a050..eaa50d0 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -158,7 +158,8 @@ def spawn( # 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 # 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) # 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"] 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: token = None with connection() as conn: diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index 0dda583..a86b32a 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -251,12 +251,14 @@ def _cmd_login_submit(command: dict) -> dict: def _cmd_skill_install(command: dict) -> dict: """Run a pasted marketplace install prompt through a one-off headless claude and - import the fetched skills as managed rows (Claude page, Skills tab).""" - prompt = _payload(command).get("prompt") + import the fetched skills as managed rows (Claude page, Skills tab). Imported rows + 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(): raise CommandError("skill_install requires a 'prompt' in the payload") 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: raise CommandError(str(exc)) from exc @@ -488,7 +490,8 @@ def run( pass did_work = drain(worker_id) > 0 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 # claude credentials before it claims its first spawn. try: diff --git a/src/handler/db/repository.py b/src/handler/db/repository.py index 2f4853e..1876bab 100644 --- a/src/handler/db/repository.py +++ b/src/handler/db/repository.py @@ -26,6 +26,8 @@ from .tables import ( agent_runs, agents, approvals, + auth_sessions, + auth_tokens, checkmarks, claude_config, claude_connectors, @@ -43,6 +45,7 @@ from .tables import ( schedules, session_archives, shared_context, + users, workers, ) from .upsert import upsert_checkmark @@ -52,6 +55,21 @@ def _now() -> datetime: return datetime.now(UTC) +# Sentinel for "no ownership filter" (admins, legacy tokens, and internal callers). +# ``visible_to=`` 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: 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 -def list_projects(conn: Connection) -> list[dict]: - rows = conn.execute(select(projects).order_by(projects.c.id)).all() +def list_projects(conn: Connection, visible_to=VISIBLE_ALL) -> list[dict]: + 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] @@ -192,6 +214,7 @@ def create_project( root_dir: str, git_remote: str | None = None, credential_ref: str | None = None, + owner_user_id: int | None = None, ) -> dict: conn.execute( projects.insert().values( @@ -199,6 +222,7 @@ def create_project( root_dir=root_dir, git_remote=git_remote, credential_ref=credential_ref, + owner_user_id=owner_user_id, 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. """ - 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} if 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( - 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]: + """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) if project_id is not None: 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( stmt.order_by(commands.c.id.desc()).limit(limit).offset(offset) ).all() @@ -971,10 +1008,15 @@ def get_runtime_secret(conn: Connection, key: str) -> dict | None: # --------------------------------------------------- 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) if enabled_only: 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() return [dict(r._mapping) for r in rows] @@ -995,6 +1037,7 @@ def create_claude_skill( content: str, description: str | None = None, enabled: bool = True, + owner_user_id: int | None = None, ) -> dict: now = _now() result = conn.execute( @@ -1003,6 +1046,7 @@ def create_claude_skill( description=description, content=content, enabled=enabled, + owner_user_id=owner_user_id, created_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) if enabled_only: 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() return [dict(r._mapping) for r in rows] @@ -1079,6 +1128,7 @@ def create_claude_connector( url: str | None = None, headers: dict | None = None, enabled: bool = True, + owner_user_id: int | None = None, ) -> dict: result = conn.execute( claude_connectors.insert().values( @@ -1090,6 +1140,7 @@ def create_claude_connector( url=url, headers=headers, enabled=enabled, + owner_user_id=owner_user_id, created_at=_now(), ) ) @@ -1115,10 +1166,15 @@ def delete_claude_connector(conn: Connection, connector_id: int) -> bool: 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) if enabled_only: 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( stmt.order_by(claude_plugins.c.marketplace, claude_plugins.c.name) ).all() @@ -1145,6 +1201,7 @@ def create_claude_plugin( marketplace: str, marketplace_repo: str, enabled: bool = True, + owner_user_id: int | None = None, ) -> dict: result = conn.execute( claude_plugins.insert().values( @@ -1152,6 +1209,7 @@ def create_claude_plugin( marketplace=marketplace, marketplace_repo=marketplace_repo, enabled=enabled, + owner_user_id=owner_user_id, created_at=_now(), ) ) @@ -1173,10 +1231,15 @@ def delete_claude_plugin(conn: Connection, plugin_id: int) -> bool: 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) if enabled_only: 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() return [dict(r._mapping) for r in rows] @@ -1201,6 +1264,7 @@ def create_claude_model( harness: str = "claude", env: dict | None = None, enabled: bool = True, + owner_user_id: int | None = None, ) -> dict: result = conn.execute( claude_models.insert().values( @@ -1212,6 +1276,7 @@ def create_claude_model( harness=harness, env=env, enabled=enabled, + owner_user_id=owner_user_id, created_at=_now(), ) ) @@ -1278,16 +1343,24 @@ def list_memory_notes( include_global: bool = True, limit: int = 200, offset: int = 0, + visible_project_ids: list[str] | None = None, ) -> list[dict]: """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 - 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) if project_id is not None: scope = memory_notes.c.project_id == project_id if include_global: scope = scope | memory_notes.c.project_id.is_(None) 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( stmt.order_by(memory_notes.c.id.desc()).limit(limit).offset(offset) ).all() @@ -1305,6 +1378,7 @@ def search_memory_notes( project_id: str | None = None, include_global: bool = True, limit: int = 20, + visible_project_ids: list[str] | None = None, ) -> list[dict]: """Case-insensitive substring search over title/body/kind, every term required. @@ -1319,6 +1393,11 @@ def search_memory_notes( if include_global: scope = scope | memory_notes.c.project_id.is_(None) 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: pattern = f"%{term}%" stmt = stmt.where( @@ -1429,10 +1508,222 @@ def delete_memory_link(conn: Connection, link_id: int) -> bool: 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 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] links = list_memory_links(conn, note_ids=ids) in_scope = set(ids) diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py index 64c3882..e83a464 100644 --- a/src/handler/db/tables.py +++ b/src/handler/db/tables.py @@ -72,6 +72,54 @@ def _in(column: str, values: tuple[str, ...]) -> str: 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", metadata, @@ -80,6 +128,9 @@ projects = Table( Column("git_remote", String), # Pointer to a secret (env:VAR / file:/path / cmd:...), never the token — README 3.7. 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()), ) @@ -354,6 +405,8 @@ claude_skills = Table( Column("description", String), Column("content", String, nullable=False), # markdown body below the front-matter 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("updated_at", PortableTimestamp, nullable=False, server_default=func.now()), ) @@ -390,6 +443,8 @@ claude_connectors = Table( Column("url", String), # http/sse: the endpoint Column("headers", PortableJSON), # http/sse: header map (may carry auth) 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()), 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_repo", String, nullable=False), # "owner/repo" or a git URL 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()), 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("env", PortableJSON), # extra env overrides (timeouts, max tokens, …), merged last 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()), ) diff --git a/src/handler/emailer.py b/src/handler/emailer.py new file mode 100644 index 0000000..99deae6 --- /dev/null +++ b/src/handler/emailer.py @@ -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 diff --git a/src/handler/migrations/versions/0016_user_accounts.py b/src/handler/migrations/versions/0016_user_accounts.py new file mode 100644 index 0000000..4630b41 --- /dev/null +++ b/src/handler/migrations/versions/0016_user_accounts.py @@ -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") diff --git a/tests/test_api_ownership.py b/tests/test_api_ownership.py new file mode 100644 index 0000000..ade6390 --- /dev/null +++ b/tests/test_api_ownership.py @@ -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 diff --git a/tests/test_api_users.py b/tests/test_api_users.py new file mode 100644 index 0000000..d4e85bc --- /dev/null +++ b/tests/test_api_users.py @@ -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 diff --git a/tests/test_claude_management.py b/tests/test_claude_management.py index accf3f6..eebe3be 100644 --- a/tests/test_claude_management.py +++ b/tests/test_claude_management.py @@ -388,7 +388,9 @@ def test_skill_install_route_enqueues(client, auth, lowpriv): assert r.status_code == 202 body = r.json() 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 ( client.post("/claude/skills/install", json={"prompt": "x"}, headers=lowpriv).status_code