mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 03:31:36 +00:00
Add email/password auth: actor-based deps and /auth routes
- deps: every request resolves to an Actor — a signed-in user (session token, hash-stored) or a legacy env token with its historical semantics. require_admin/require_shared_write now accept admin users. - /auth: status probe, first-run setup (first account = admin), login, logout, me, change-password, forgot/reset (one-shot links), and admin user management with invite links + a last-admin lockout guard. - Invite/reset links are emailed when SMTP is configured and always returned to the admin; forgot never reveals account existence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
This commit is contained in:
@@ -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)
|
||||
|
||||
+121
-37
@@ -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,117 @@ 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). Order matters for the
|
||||
# historical fallbacks: with ADMIN_TOKEN unset it falls back to AUTH_TOKEN, so the
|
||||
# plain token must come out admin — checking the admin value first guarantees that.
|
||||
if _check(token, settings.effective_admin_token):
|
||||
return Actor(kind="token", is_admin=True, shared_write=True)
|
||||
if _check(token, settings.effective_shared_write_token):
|
||||
return Actor(kind="token", shared_write=True)
|
||||
if _check(token, settings.auth_token):
|
||||
return Actor(kind="token")
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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}
|
||||
@@ -104,6 +104,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 +411,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 +475,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 +525,7 @@ class ClaudePluginOut(BaseModel):
|
||||
marketplace: str
|
||||
marketplace_repo: str
|
||||
enabled: bool
|
||||
owner_user_id: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -587,6 +592,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 +686,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
|
||||
|
||||
Reference in New Issue
Block a user