From 6555f1ad79fbf5afb401bc379ace2c5bf0c8c0fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:24:22 +0000 Subject: [PATCH] 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