From a414a18fde4b741011d8dd03430f7b18f9792484 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:10:31 +0000 Subject: [PATCH 1/8] Add user account schema: users, sessions, one-shot tokens, ownership columns users/auth_sessions/auth_tokens tables plus a nullable owner_user_id on projects, claude_skills, claude_connectors, claude_plugins, and claude_models (null = shared/legacy, so upgrades keep behaving as before). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- src/handler/db/tables.py | 59 ++++++++++++++ .../migrations/versions/0016_user_accounts.py | 79 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/handler/migrations/versions/0016_user_accounts.py 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/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") From deb67c17cf661314875562410aea63a3bb2efa5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:13:33 +0000 Subject: [PATCH 2/8] Add auth helpers, SMTP emailer, and user/session repository layer - handler.authn: stdlib scrypt password hashing + opaque token helpers (tokens stored only as SHA-256). - handler.emailer: plain-SMTP delivery for invites/resets; unconfigured SMTP degrades to returning links instead of mailing them. - config: SMTP_*, PUBLIC_BASE_URL, session/reset/invite TTLs. - repository: user/session/one-shot-token CRUD, lockout-guard counts, and shared-plus-mine visibility filters (VISIBLE_ALL sentinel) on projects, skills, connectors, plugins, models, commands, and memory notes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- src/handler/authn.py | 73 ++++++++ src/handler/config.py | 26 ++- src/handler/db/repository.py | 313 +++++++++++++++++++++++++++++++++-- src/handler/emailer.py | 55 ++++++ 4 files changed, 455 insertions(+), 12 deletions(-) create mode 100644 src/handler/authn.py create mode 100644 src/handler/emailer.py 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/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/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 From c227faa76d47d1ac4a308c48f8a4fc814712980a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:16:27 +0000 Subject: [PATCH 3/8] Add email/password auth: actor-based deps and /auth routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- src/handler/api/app.py | 2 + src/handler/api/deps.py | 158 +++++++++++---- src/handler/api/routes/auth.py | 341 +++++++++++++++++++++++++++++++++ src/handler/api/schemas.py | 110 +++++++++++ 4 files changed, 574 insertions(+), 37 deletions(-) create mode 100644 src/handler/api/routes/auth.py 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..3357c84 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,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 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/schemas.py b/src/handler/api/schemas.py index f8b9594..84d280b 100644 --- a/src/handler/api/schemas.py +++ b/src/handler/api/schemas.py @@ -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 From 6555f1ad79fbf5afb401bc379ace2c5bf0c8c0fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:24:22 +0000 Subject: [PATCH 4/8] Scope API resources per user (shared + owned visibility, owner-or-admin edits) Projects, agents, interaction, approvals, schedules, memory, commands, and the Claude page resources (skills/connectors/plugins/models) now resolve through the Actor's ownership rules: users see shared rows plus their own, creates stamp the creating user as owner, mutations require the owner or an admin, and invisible resources 404. Legacy env tokens keep their exact historical semantics (all-access; admin token for the old admin-gated routes). Commands from a user carry a user: audit label so they can track their own non-project commands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- src/handler/api/deps.py | 17 +- src/handler/api/routes/agents.py | 79 +++++---- src/handler/api/routes/approvals.py | 23 ++- src/handler/api/routes/claude.py | 225 ++++++++++++++++++-------- src/handler/api/routes/commands.py | 32 +++- src/handler/api/routes/common.py | 49 +++++- src/handler/api/routes/interaction.py | 11 +- src/handler/api/routes/memory.py | 120 ++++++++++---- src/handler/api/routes/projects.py | 99 +++++++----- src/handler/api/routes/schedules.py | 67 +++++--- src/handler/api/schemas.py | 2 + tests/test_claude_management.py | 4 +- 12 files changed, 506 insertions(+), 222 deletions(-) diff --git a/src/handler/api/deps.py b/src/handler/api/deps.py index 3357c84..66f935e 100644 --- a/src/handler/api/deps.py +++ b/src/handler/api/deps.py @@ -103,15 +103,14 @@ def get_actor( 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") + # 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) 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/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 84d280b..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 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 From 68c24f3a4b016c36dee56f3b61b6c0309cd5174d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:26:15 +0000 Subject: [PATCH 5/8] Apply per-user skills/connectors at launch; stamp installed skills' owner claude_gen.apply now takes the launching project's owner and materializes only shared rows plus that user's own; spawn and resume pass it through. skill_install stamps imported rows with the requesting user from the command payload (reinstalls keep the existing owner). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- src/handler/control/claude_gen.py | 25 +++++++++++++++++-------- src/handler/control/skill_install.py | 18 ++++++++++++------ src/handler/control/spawn.py | 5 +++-- src/handler/control/worker.py | 11 +++++++---- 4 files changed, 39 insertions(+), 20 deletions(-) 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: From 110772580a76c9be31000344a0c4eba4648c3cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:30:16 +0000 Subject: [PATCH 6/8] Add auth and per-user separation test suites 24 new tests: setup/login/session lifecycle, invites, resets (with and without SMTP), admin guards and the last-admin lockout guard, resource reassignment on user deletion, cross-user 404s on projects/skills/ connectors/plugins/models/schedules/commands/memory, private model backends rejected at spawn/schedule time, and claude_gen materializing only shared + owner rows at launch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- tests/test_api_ownership.py | 281 ++++++++++++++++++++++++++++++++++++ tests/test_api_users.py | 250 ++++++++++++++++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 tests/test_api_ownership.py create mode 100644 tests/test_api_users.py 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 From 722a2f344c9766f597b90f9d13e81e4988b30dea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:37:08 +0000 Subject: [PATCH 7/8] Frontend: email sign-in, first-run setup, reset links, Users admin page - AuthGate replaces the raw token prompt: first-run setup form (creates the admin) when no accounts exist, email/password sign-in with a forgot-password flow, and a collapsible raw-API-token fallback for legacy/script setups. - /reset is a public page where invite and password-reset links land; success stores the fresh session and enters the dashboard. - Users section (admin-only nav): invite by email (link always shown, emailed when SMTP is configured), admin/disable toggles, reset links, and delete with the shared-resources handoff spelled out. - Sidebar shows who is signed in; sign-out revokes the session server-side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- frontend/app/globals.css | 15 ++ frontend/app/reset/page.tsx | 99 ++++++++ frontend/app/users/page.tsx | 12 + frontend/components/AppFrame.tsx | 36 ++- frontend/components/AuthGate.tsx | 211 ++++++++++++++++++ frontend/components/Shell.tsx | 24 +- frontend/components/TokenGate.tsx | 48 ---- frontend/components/sections/UsersSection.tsx | 161 +++++++++++++ frontend/components/store.tsx | 131 ++++++++++- frontend/lib/api.ts | 71 ++++++ frontend/lib/nav.ts | 2 + 11 files changed, 751 insertions(+), 59 deletions(-) create mode 100644 frontend/app/reset/page.tsx create mode 100644 frontend/app/users/page.tsx create mode 100644 frontend/components/AuthGate.tsx delete mode 100644 frontend/components/TokenGate.tsx create mode 100644 frontend/components/sections/UsersSection.tsx 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 From eeb8259ba46ef34cf91cb946ce2395d69fc44a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:38:32 +0000 Subject: [PATCH 8/8] Document user accounts: README section, config table, changelog, .env.example Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- .env.example | 25 ++++++++++++++++++- CHANGELOG.md | 46 +++++++++++++++++++++++++++++++++++ README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 135 insertions(+), 4 deletions(-) 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 7c3c066..ed43519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,52 @@ the image workflows publish (plus `latest` from every push to `main`). ## [Unreleased] +### 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 |