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