mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 06:56:24 +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 (
|
from .routes import (
|
||||||
agents,
|
agents,
|
||||||
approvals,
|
approvals,
|
||||||
|
auth,
|
||||||
claude,
|
claude,
|
||||||
commands,
|
commands,
|
||||||
hosts,
|
hosts,
|
||||||
@@ -44,6 +45,7 @@ def create_app() -> FastAPI:
|
|||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(agents.router)
|
app.include_router(agents.router)
|
||||||
app.include_router(interaction.router)
|
app.include_router(interaction.router)
|
||||||
|
|||||||
+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
|
Two kinds of callers hold a bearer token:
|
||||||
writes may require a separate higher-trust token (README 3.4), falling back to the
|
|
||||||
global token when unset.
|
- **Users** — email + password accounts (``/auth``). Their bearer is an opaque session
|
||||||
|
token minted at login; the database stores only its hash. A user is either an admin
|
||||||
|
(sees and manages everything) or a regular account, which sees *shared* resources
|
||||||
|
(owner NULL) plus its own — the per-user separation of projects, skills, and tools.
|
||||||
|
- **Legacy env tokens** — ``AUTH_TOKEN`` / ``SHARED_CONTEXT_WRITE_TOKEN`` /
|
||||||
|
``ADMIN_TOKEN``, compared in constant time exactly as before user accounts existed.
|
||||||
|
They keep working for scripts/CI and as a break-glass credential, with their original
|
||||||
|
semantics: they see every resource, and the admin token passes the admin gates.
|
||||||
|
|
||||||
|
Every request resolves to one :class:`Actor`; route handlers consult it for ownership
|
||||||
|
decisions (``visible_scope`` / ``can_edit``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy import Connection
|
from sqlalchemy import Connection
|
||||||
|
|
||||||
|
from .. import authn
|
||||||
from ..config import Settings, get_settings
|
from ..config import Settings, get_settings
|
||||||
|
from ..db import repository as repo
|
||||||
from ..db.engine import connection
|
from ..db.engine import connection
|
||||||
|
|
||||||
_bearer = HTTPBearer(auto_error=False)
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
# How stale a session's last_used_at may get before we write a fresh one (the dashboard
|
||||||
|
# polls every few seconds; a write per poll would be pure churn).
|
||||||
|
_TOUCH_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
def db_conn() -> Iterator[Connection]:
|
def db_conn() -> Iterator[Connection]:
|
||||||
with connection() as conn:
|
with connection() as conn:
|
||||||
@@ -31,52 +50,117 @@ def _check(provided: str | None, expected: str) -> bool:
|
|||||||
return secrets.compare_digest(provided, expected)
|
return secrets.compare_digest(provided, expected)
|
||||||
|
|
||||||
|
|
||||||
def require_auth(
|
@dataclass(frozen=True)
|
||||||
|
class Actor:
|
||||||
|
"""Who is making this request: a signed-in user or a legacy env token."""
|
||||||
|
|
||||||
|
kind: str # "user" | "token"
|
||||||
|
user_id: int | None = None
|
||||||
|
email: str | None = None
|
||||||
|
is_admin: bool = False
|
||||||
|
shared_write: bool = False # may write shared_context keys
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sees_all(self) -> bool:
|
||||||
|
"""Admins and legacy tokens see every resource (tokens keep their historical
|
||||||
|
all-access semantics for scripts); regular users see shared + their own."""
|
||||||
|
return self.is_admin or self.kind == "token"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visible_scope(self):
|
||||||
|
"""The ``visible_to`` argument for repository list functions."""
|
||||||
|
return repo.VISIBLE_ALL if self.sees_all else self.user_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
"""The ``requested_by`` audit label for commands this actor enqueues."""
|
||||||
|
if self.kind == "user":
|
||||||
|
return f"user:{self.user_id}:{self.email}"
|
||||||
|
return "operator:web"
|
||||||
|
|
||||||
|
def can_edit(self, owner_user_id: int | None) -> bool:
|
||||||
|
"""Mutation rule for owned resources: admins (and the legacy admin token) edit
|
||||||
|
anything; a user edits what they own. Shared rows (owner NULL) are admin-managed."""
|
||||||
|
if self.is_admin:
|
||||||
|
return True
|
||||||
|
if self.kind == "user":
|
||||||
|
return owner_user_id is not None and owner_user_id == self.user_id
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_view(self, owner_user_id: int | None) -> bool:
|
||||||
|
if self.sees_all:
|
||||||
|
return True
|
||||||
|
return owner_user_id is None or owner_user_id == self.user_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_actor(
|
||||||
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
settings: Settings = Depends(get_settings),
|
settings: Settings = Depends(get_settings),
|
||||||
) -> None:
|
conn: Connection = Depends(db_conn),
|
||||||
|
) -> Actor:
|
||||||
|
"""Resolve the request's bearer to an :class:`Actor` or raise 401."""
|
||||||
token = creds.credentials if creds else None
|
token = creds.credentials if creds else None
|
||||||
# The shared-context write and admin tokens are higher-trust, so they also grant
|
if token is None:
|
||||||
# normal access; a single request carries one bearer, and it should never be rejected
|
raise _unauthorized()
|
||||||
# for being the more privileged one.
|
|
||||||
valid = (
|
# Legacy env tokens first (cheap constant-time compares). Order matters for the
|
||||||
_check(token, settings.auth_token)
|
# historical fallbacks: with ADMIN_TOKEN unset it falls back to AUTH_TOKEN, so the
|
||||||
or _check(token, settings.effective_shared_write_token)
|
# plain token must come out admin — checking the admin value first guarantees that.
|
||||||
or _check(token, settings.effective_admin_token)
|
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(
|
|
||||||
|
def _unauthorized() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="invalid or missing bearer token",
|
detail="invalid or missing bearer token",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_shared_write(
|
def require_auth(actor: Actor = Depends(get_actor)) -> Actor:
|
||||||
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
return actor
|
||||||
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):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="shared-context write requires the shared-context write token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin(
|
def require_shared_write(actor: Actor = Depends(get_actor)) -> Actor:
|
||||||
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
"""Gate for shared_context writes — the one table every project implicitly trusts.
|
||||||
settings: Settings = Depends(get_settings),
|
Admin users, the admin token, and the dedicated shared-write token qualify."""
|
||||||
) -> None:
|
if not actor.shared_write:
|
||||||
"""Gate for the web control surface: enqueuing control commands, project/host CRUD,
|
|
||||||
and credential-pointer edits. Requires specifically the admin token (which defaults to
|
|
||||||
the global token when ADMIN_TOKEN is unset)."""
|
|
||||||
token = creds.credentials if creds else None
|
|
||||||
if not _check(token, settings.effective_admin_token):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="this action requires the admin token",
|
detail="shared-context write requires the shared-context write token or an admin",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
return actor
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(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 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
|
root_dir: str
|
||||||
git_remote: str | None = None
|
git_remote: str | None = None
|
||||||
credential_ref: str | None = None
|
credential_ref: str | None = None
|
||||||
|
# Owning user account; null = shared/legacy (visible to everyone, admin-managed).
|
||||||
|
owner_user_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -409,6 +411,7 @@ class ClaudeSkillOut(BaseModel):
|
|||||||
# Relative paths of auxiliary files (references/, scripts/, …) captured by the
|
# Relative paths of auxiliary files (references/, scripts/, …) captured by the
|
||||||
# install-from-prompt import; synced alongside SKILL.md, read-only over the API.
|
# install-from-prompt import; synced alongside SKILL.md, read-only over the API.
|
||||||
files: list[str] = Field(default_factory=list)
|
files: list[str] = Field(default_factory=list)
|
||||||
|
owner_user_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -472,6 +475,7 @@ class ClaudeConnectorOut(BaseModel):
|
|||||||
url: str | None = None
|
url: str | None = None
|
||||||
headers: dict[str, str] | None = None
|
headers: dict[str, str] | None = None
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
owner_user_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -521,6 +525,7 @@ class ClaudePluginOut(BaseModel):
|
|||||||
marketplace: str
|
marketplace: str
|
||||||
marketplace_repo: str
|
marketplace_repo: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
owner_user_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -587,6 +592,7 @@ class ClaudeModelOut(BaseModel):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
# The key never leaves the server; this says whether one is stored.
|
# The key never leaves the server; this says whether one is stored.
|
||||||
has_api_key: bool = False
|
has_api_key: bool = False
|
||||||
|
owner_user_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -680,3 +686,107 @@ class MemoryGraphOut(BaseModel):
|
|||||||
|
|
||||||
notes: list[MemoryNoteOut]
|
notes: list[MemoryNoteOut]
|
||||||
links: list[MemoryLinkOut]
|
links: list[MemoryLinkOut]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- user accounts & sessions (``/auth``) ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatusOut(BaseModel):
|
||||||
|
"""Unauthenticated bootstrap probe: does the login page show a sign-in form or the
|
||||||
|
first-run setup form, and can the deployment send email?"""
|
||||||
|
|
||||||
|
initialized: bool # any user account exists
|
||||||
|
smtp_configured: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
is_admin: bool
|
||||||
|
disabled: bool
|
||||||
|
# False until an invited user sets their password through the invite link.
|
||||||
|
has_password: bool = False
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SetupIn(BaseModel):
|
||||||
|
"""First-run: create the very first account, which becomes the admin."""
|
||||||
|
|
||||||
|
email: str = Field(min_length=3, max_length=254)
|
||||||
|
password: str = Field(min_length=8, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginIn(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=254)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionOut(BaseModel):
|
||||||
|
"""A fresh bearer session token plus who it belongs to."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class MeOut(BaseModel):
|
||||||
|
"""Who the presented bearer resolves to. Legacy env tokens have no user identity —
|
||||||
|
``kind == "token"`` with null user fields."""
|
||||||
|
|
||||||
|
kind: str # "user" | "token"
|
||||||
|
user_id: int | None = None
|
||||||
|
email: str | None = None
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordIn(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
new_password: str = Field(min_length=8, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotIn(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=254)
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotOut(BaseModel):
|
||||||
|
"""Always ``ok`` — whether the address has an account is deliberately not revealed."""
|
||||||
|
|
||||||
|
ok: bool = True
|
||||||
|
emailed: bool # False when SMTP is not configured (ask an admin for a reset link)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetIn(BaseModel):
|
||||||
|
"""Complete a reset or invite link: spend the one-shot token, set the password."""
|
||||||
|
|
||||||
|
token: str = Field(min_length=1, max_length=256)
|
||||||
|
password: str = Field(min_length=8, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateIn(BaseModel):
|
||||||
|
"""Admin creates an account; the new user sets their password via the invite link."""
|
||||||
|
|
||||||
|
email: str = Field(min_length=3, max_length=254)
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreatedOut(BaseModel):
|
||||||
|
user: UserOut
|
||||||
|
# The invite link is always returned (the admin can hand it over out-of-band);
|
||||||
|
# ``emailed`` says whether it was also delivered by SMTP.
|
||||||
|
invite_url: str
|
||||||
|
emailed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateIn(BaseModel):
|
||||||
|
"""Admin edits; omit a field to leave it unchanged."""
|
||||||
|
|
||||||
|
is_admin: bool | None = None
|
||||||
|
disabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResetLinkOut(BaseModel):
|
||||||
|
"""An admin-minted reset (or invite) link for a user."""
|
||||||
|
|
||||||
|
reset_url: str
|
||||||
|
emailed: bool
|
||||||
|
|||||||
Reference in New Issue
Block a user