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:<id> audit
label so they can track their own non-project commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
This commit is contained in:
Claude
2026-08-12 19:24:22 +00:00
parent c227faa76d
commit 6555f1ad79
12 changed files with 506 additions and 222 deletions
+8 -9
View File
@@ -103,15 +103,14 @@ def get_actor(
if token is None: if token is None:
raise _unauthorized() raise _unauthorized()
# Legacy env tokens first (cheap constant-time compares). Order matters for the # Legacy env tokens first (cheap constant-time compares). Each capability is
# historical fallbacks: with ADMIN_TOKEN unset it falls back to AUTH_TOKEN, so the # checked independently so the historical fallbacks hold exactly: with ADMIN_TOKEN
# plain token must come out admin — checking the admin value first guarantees that. # unset the plain token comes out admin, while a dedicated admin token does *not*
if _check(token, settings.effective_admin_token): # inherit shared-context write (that stays with the shared-write token, as before).
return Actor(kind="token", is_admin=True, shared_write=True) token_admin = _check(token, settings.effective_admin_token)
if _check(token, settings.effective_shared_write_token): token_shared = _check(token, settings.effective_shared_write_token)
return Actor(kind="token", shared_write=True) if token_admin or token_shared or _check(token, settings.auth_token):
if _check(token, settings.auth_token): return Actor(kind="token", is_admin=token_admin, shared_write=token_shared)
return Actor(kind="token")
# Otherwise it may be a user session token (hash-stored). # Otherwise it may be a user session token (hash-stored).
token_hash = authn.hash_token(token) token_hash = authn.hash_token(token)
+52 -27
View File
@@ -13,7 +13,7 @@ from sqlalchemy import Connection
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from ...db import repository as repo 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 ( from ..schemas import (
AgentEventOut, AgentEventOut,
AgentIn, AgentIn,
@@ -23,7 +23,7 @@ from ..schemas import (
LogEntryOut, LogEntryOut,
SpawnIn, SpawnIn,
) )
from .common import resolve_agent from .common import resolve_agent, resolve_project
router = APIRouter( router = APIRouter(
prefix="/projects/{project}/agents", 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]) @router.get("", response_model=list[AgentOut])
def list_agents(project: str, conn: Connection = Depends(db_conn)) -> list[dict]: def list_agents(
_require_project(conn, project) 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) return repo.list_agents(conn, project)
@router.post("", response_model=AgentOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=AgentOut, status_code=status.HTTP_201_CREATED)
def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn)) -> dict: def create_agent(
_require_project(conn, project) 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: if repo.get_agent_by_name(conn, project, body.name) is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
@@ -68,11 +72,15 @@ def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn
"/spawn", "/spawn",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, 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.""" """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: if repo.get_agent_by_name(conn, project, body.name) is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, 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: if body.model_id is not None:
# Same fail-fast idea for the model dropdown: the worker re-checks at launch, # 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. # 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) 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( raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=f"model {body.model_id} not found" 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, project_id=project,
agent_name=body.name, agent_name=body.name,
payload=payload, 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", "/{name}/kill",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_kill(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: def enqueue_kill(
resolve_agent(conn, project, name) 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( 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)]) @router.delete("/{name}")
def delete_agent(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: 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).""" """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) repo.delete_agent(conn, project, name)
return {"deleted": name} return {"deleted": name}
@router.get("/{name}/checkmark", response_model=CheckmarkOut) @router.get("/{name}/checkmark", response_model=CheckmarkOut)
def get_checkmark(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: def get_checkmark(
agent = resolve_agent(conn, project, name) 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"]) checkmark = repo.get_checkmark(conn, agent["id"])
if checkmark is None: if checkmark is None:
raise HTTPException( raise HTTPException(
@@ -149,6 +172,7 @@ def get_events(
name: str, name: str,
after_id: int = Query(0, ge=0), after_id: int = Query(0, ge=0),
limit: int = Query(200, ge=1, le=1000), limit: int = Query(200, ge=1, le=1000),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
"""The headless run event stream, oldest-first, cursor-paged by row id. """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 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). 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) return repo.list_agent_events(conn, agent["id"], after_id=after_id, limit=limit)
@@ -166,7 +190,8 @@ def get_log(
name: str, name: str,
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> 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) return repo.get_log(conn, agent["id"], limit=limit, offset=offset)
+11 -12
View File
@@ -8,12 +8,13 @@ treats as a genuine second party (satisfying the "no self-approval" rule).
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo 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 ..schemas import ApprovalIn, ApprovalOut, CommandOut
from .common import resolve_project
router = APIRouter( router = APIRouter(
prefix="/projects/{project}/approvals", 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]) @router.get("", response_model=list[ApprovalOut])
def list_approvals( def list_approvals(
project: str, project: str,
branch: str | None = Query(None), branch: str | None = Query(None),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
_require_project(conn, project) resolve_project(conn, project, actor)
return repo.list_approvals(conn, project, branch=branch) return repo.list_approvals(conn, project, branch=branch)
@@ -41,12 +38,14 @@ def list_approvals(
"", "",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_approval( 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: ) -> dict:
_require_project(conn, project) resolve_project(conn, project, actor, edit=True)
payload = { payload = {
"branch": body.branch, "branch": body.branch,
"sha": body.sha, "sha": body.sha,
@@ -61,5 +60,5 @@ def enqueue_approval(
project_id=project, project_id=project,
agent_name=body.agent_name, agent_name=body.agent_name,
payload={k: v for k, v in payload.items() if v is not None}, payload={k: v for k, v in payload.items() if v is not None},
requested_by="operator:web", requested_by=actor.label,
) )
+162 -63
View File
@@ -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 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. 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 Skills, connectors, plugins, and model backends are per-user resources: everyone sees
is allowed to do). The login flow stays under ``/login`` — it needs the worker's tmux. 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 from __future__ import annotations
@@ -19,7 +22,7 @@ from sqlalchemy import Connection
from ... import secretstore from ... import secretstore
from ...config import get_settings from ...config import get_settings
from ...db import repository as repo 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 ( from ..schemas import (
ClaudeConnectorIn, ClaudeConnectorIn,
ClaudeConnectorOut, ClaudeConnectorOut,
@@ -42,12 +45,32 @@ from ..schemas import (
router = APIRouter(prefix="/claude", tags=["claude"], dependencies=[Depends(require_auth)]) 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 --------------------------------------------------------------------------- # ---- 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) 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") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"skill {skill_id} not found")
return skill return skill
@@ -60,21 +83,35 @@ def _skill_out(conn: Connection, row: dict) -> dict:
@router.get("/skills", response_model=list[ClaudeSkillOut]) @router.get("/skills", response_model=list[ClaudeSkillOut])
def list_skills(conn: Connection = Depends(db_conn)) -> list[dict]: def list_skills(
return [_skill_out(conn, s) for s in repo.list_claude_skills(conn)] 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( @router.post(
"/skills", "/skills",
response_model=ClaudeSkillOut, response_model=ClaudeSkillOut,
status_code=status.HTTP_201_CREATED, 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: 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") raise HTTPException(status.HTTP_409_CONFLICT, detail=f"skill '{body.name}' exists")
return repo.create_claude_skill( 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", "/skills/install",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, 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 """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 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 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( 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( @router.patch("/skills/{skill_id}", response_model=ClaudeSkillOut)
"/skills/{skill_id}", response_model=ClaudeSkillOut, dependencies=[Depends(require_admin)]
)
def update_skill( 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: ) -> 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) fields = body.model_dump(exclude_unset=True)
if "name" in fields: if "name" in fields:
clash = repo.get_claude_skill_by_name(conn, fields["name"]) 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)) return _skill_out(conn, repo.update_claude_skill(conn, skill_id, **fields))
@router.delete("/skills/{skill_id}", dependencies=[Depends(require_admin)]) @router.delete("/skills/{skill_id}")
def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_skill(
skill = _skill_or_404(conn, skill_id) 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) repo.delete_claude_skill(conn, skill_id)
return {"deleted": skill["name"]} return {"deleted": skill["name"]}
@@ -121,9 +172,9 @@ def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict:
# ---- connectors (MCP servers) --------------------------------------------------------- # ---- 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) 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( raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"connector {connector_id} not found" 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]) @router.get("/connectors", response_model=list[ClaudeConnectorOut])
def list_connectors(conn: Connection = Depends(db_conn)) -> list[dict]: def list_connectors(
return repo.list_claude_connectors(conn) 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( @router.post(
"/connectors", "/connectors",
response_model=ClaudeConnectorOut, response_model=ClaudeConnectorOut,
status_code=status.HTTP_201_CREATED, 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: 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") raise HTTPException(status.HTTP_409_CONFLICT, detail=f"connector '{body.name}' exists")
return repo.create_claude_connector( return repo.create_claude_connector(
@@ -154,18 +211,22 @@ def create_connector(body: ClaudeConnectorIn, conn: Connection = Depends(db_conn
url=body.url, url=body.url,
headers=body.headers, headers=body.headers,
enabled=body.enabled, enabled=body.enabled,
owner_user_id=actor.user_id,
) )
@router.patch( @router.patch(
"/connectors/{connector_id}", "/connectors/{connector_id}",
response_model=ClaudeConnectorOut, response_model=ClaudeConnectorOut,
dependencies=[Depends(require_admin)],
) )
def update_connector( 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: ) -> 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) fields = body.model_dump(exclude_unset=True)
if "name" in fields: if "name" in fields:
clash = repo.get_claude_connector_by_name(conn, fields["name"]) 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) return repo.update_claude_connector(conn, connector_id, **fields)
@router.delete("/connectors/{connector_id}", dependencies=[Depends(require_admin)]) @router.delete("/connectors/{connector_id}")
def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_connector(
connector = _connector_or_404(conn, connector_id) 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) repo.delete_claude_connector(conn, connector_id)
return {"deleted": connector["name"]} return {"deleted": connector["name"]}
@@ -199,42 +265,55 @@ def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) ->
# ---- plugins -------------------------------------------------------------------------- # ---- 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) 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") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"plugin {plugin_id} not found")
return plugin return plugin
@router.get("/plugins", response_model=list[ClaudePluginOut]) @router.get("/plugins", response_model=list[ClaudePluginOut])
def list_plugins(conn: Connection = Depends(db_conn)) -> list[dict]: def list_plugins(
return repo.list_claude_plugins(conn) 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( @router.post(
"/plugins", "/plugins",
response_model=ClaudePluginOut, response_model=ClaudePluginOut,
status_code=status.HTTP_201_CREATED, 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: if repo.get_claude_plugin_by_key(conn, body.name, body.marketplace) is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
detail=f"plugin '{body.name}@{body.marketplace}' exists", detail=f"plugin '{body.name}@{body.marketplace}' exists",
) )
return repo.create_claude_plugin( 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( @router.patch("/plugins/{plugin_id}", response_model=ClaudePluginOut)
"/plugins/{plugin_id}", response_model=ClaudePluginOut, dependencies=[Depends(require_admin)]
)
def update_plugin( 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: ) -> 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) fields = body.model_dump(exclude_unset=True)
if "name" in fields or "marketplace" in fields: if "name" in fields or "marketplace" in fields:
merged = {**current, **fields} merged = {**current, **fields}
@@ -247,9 +326,14 @@ def update_plugin(
return repo.update_claude_plugin(conn, plugin_id, **fields) return repo.update_claude_plugin(conn, plugin_id, **fields)
@router.delete("/plugins/{plugin_id}", dependencies=[Depends(require_admin)]) @router.delete("/plugins/{plugin_id}")
def delete_plugin(plugin_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_plugin(
plugin = _plugin_or_404(conn, plugin_id) 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) repo.delete_claude_plugin(conn, plugin_id)
return {"deleted": f"{plugin['name']}@{plugin['marketplace']}"} 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. # 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) 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") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"model {model_id} not found")
return row return row
@@ -282,17 +366,25 @@ def _encrypt_key_or_400(value: str) -> str:
@router.get("/models", response_model=list[ClaudeModelOut]) @router.get("/models", response_model=list[ClaudeModelOut])
def list_models(conn: Connection = Depends(db_conn)) -> list[dict]: def list_models(
return [_model_out(m) for m in repo.list_claude_models(conn)] 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( @router.post(
"/models", "/models",
response_model=ClaudeModelOut, response_model=ClaudeModelOut,
status_code=status.HTTP_201_CREATED, 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: 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") 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 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, harness=body.harness,
env=body.env, env=body.env,
enabled=body.enabled, enabled=body.enabled,
owner_user_id=actor.user_id,
) )
) )
@router.patch( @router.patch("/models/{model_id}", response_model=ClaudeModelOut)
"/models/{model_id}", response_model=ClaudeModelOut, dependencies=[Depends(require_admin)]
)
def update_model( 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: ) -> 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) fields = body.model_dump(exclude_unset=True)
if "name" in fields: if "name" in fields:
clash = repo.get_claude_model_by_name(conn, fields["name"]) 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)) return _model_out(repo.update_claude_model(conn, model_id, **fields))
@router.delete("/models/{model_id}", dependencies=[Depends(require_admin)]) @router.delete("/models/{model_id}")
def delete_model(model_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_model(
row = _model_or_404(conn, model_id) 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) repo.delete_claude_model(conn, model_id)
return {"deleted": row["name"]} return {"deleted": row["name"]}
+26 -6
View File
@@ -12,8 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo 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 ..schemas import CommandOut
from .common import visible_project_ids
router = APIRouter(tags=["commands"], dependencies=[Depends(require_auth)]) router = APIRouter(tags=["commands"], dependencies=[Depends(require_auth)])
@@ -23,14 +24,32 @@ def list_commands(
project: str | None = Query(None), project: str | None = Query(None),
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> 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) @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) 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: if command is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"command {command_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"command {command_id} not found")
return command return command
@@ -40,8 +59,9 @@ def get_command(command_id: int, conn: Connection = Depends(db_conn)) -> dict:
"/poll-ci", "/poll-ci",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, 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).""" """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)
+41 -8
View File
@@ -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 from __future__ import annotations
@@ -6,16 +14,41 @@ from fastapi import HTTPException, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo from ...db import repository as repo
from ..deps import Actor
def resolve_agent(conn: Connection, project: str, name: str) -> dict: def resolve_project(
"""Fetch an agent by ``(project, name)`` or 404. 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. 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 repo.get_project(conn, project) is None: if actor.sees_all:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found") 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) agent = repo.get_agent_by_name(conn, project, name)
if agent is None: if agent is None:
raise HTTPException( raise HTTPException(
+6 -5
View File
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo 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 ..schemas import AnswerIn, AnswerOut, CommandOut, ResumeIn
from .common import resolve_agent from .common import resolve_agent
@@ -30,9 +30,10 @@ def answer(
project: str, project: str,
name: str, name: str,
body: AnswerIn, body: AnswerIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> AnswerOut: ) -> AnswerOut:
agent = resolve_agent(conn, project, name) agent = resolve_agent(conn, project, name, actor)
if body.log_entry_id is not None: if body.log_entry_id is not None:
log_entry_id = body.log_entry_id log_entry_id = body.log_entry_id
@@ -58,15 +59,15 @@ def answer(
"/resume", "/resume",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def resume( def resume(
project: str, project: str,
name: str, name: str,
body: ResumeIn, body: ResumeIn,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> dict: ) -> 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. # Resolve the answer to feed back here (the API has the log); the worker just delivers.
answer_text = body.answer answer_text = body.answer
@@ -92,5 +93,5 @@ def resume(
project_id=project, project_id=project,
agent_name=name, agent_name=name,
payload={"answer": answer_text}, payload={"answer": answer_text},
requested_by="operator:web", requested_by=actor.label,
) )
+88 -32
View File
@@ -3,8 +3,10 @@
Agents write here through the bundled handler-memory MCP server (direct DB, like the 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 — 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 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 management pages. Visibility follows the note's project (global notes are visible to
feed every future agent's context, so authoring them is a control-surface action). 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 from __future__ import annotations
@@ -13,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo 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 ( from ..schemas import (
MemoryGraphOut, MemoryGraphOut,
MemoryLinkIn, MemoryLinkIn,
@@ -22,22 +24,37 @@ from ..schemas import (
MemoryNoteOut, MemoryNoteOut,
MemoryNoteUpdateIn, MemoryNoteUpdateIn,
) )
from .common import resolve_project, visible_project_ids
router = APIRouter(prefix="/memory", tags=["memory"], dependencies=[Depends(require_auth)]) 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) 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: if note is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"note {note_id} not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"note {note_id} not found")
return note return note
def _project_or_400(conn: Connection, project_id: str | None) -> None: def _require_note_edit(conn: Connection, note_project_id: str | None, actor: Actor) -> None:
if project_id is not None and repo.get_project(conn, project_id) is 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( 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]) @router.get("/notes", response_model=list[MemoryNoteOut])
@@ -46,36 +63,52 @@ def list_notes(
q: str | None = Query(None), q: str | None = Query(None),
limit: int = Query(200, ge=1, le=1000), limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> list[dict]: ) -> list[dict]:
"""Notes in scope, newest first; ``q`` switches to substring search (all terms).""" """Notes in scope, newest first; ``q`` switches to substring search (all terms)."""
visible = visible_project_ids(conn, actor)
if q: if q:
return repo.search_memory_notes(conn, q, project_id=project_id, limit=limit) return repo.search_memory_notes(
return repo.list_memory_notes(conn, project_id=project_id, limit=limit, offset=offset) 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) @router.get("/graph", response_model=MemoryGraphOut)
def graph( def graph(
project_id: str | None = Query(None), project_id: str | None = Query(None),
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn), conn: Connection = Depends(db_conn),
) -> dict: ) -> dict:
"""The whole web of notes in one read — what the Memory page draws.""" """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) @router.get("/notes/{note_id}", response_model=MemoryNoteOut)
def get_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict: def get_note(
return _note_or_404(conn, note_id) note_id: int,
actor: Actor = Depends(get_actor),
conn: Connection = Depends(db_conn),
) -> dict:
return _note_or_404(conn, note_id, actor)
@router.post( @router.post(
"/notes", "/notes",
response_model=MemoryNoteOut, response_model=MemoryNoteOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_note(body: MemoryNoteIn, conn: Connection = Depends(db_conn)) -> dict: def create_note(
_project_or_400(conn, body.project_id) 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( return repo.create_memory_note(
conn, conn,
title=body.title, title=body.title,
@@ -87,22 +120,30 @@ def create_note(body: MemoryNoteIn, conn: Connection = Depends(db_conn)) -> dict
) )
@router.patch( @router.patch("/notes/{note_id}", response_model=MemoryNoteOut)
"/notes/{note_id}", response_model=MemoryNoteOut, dependencies=[Depends(require_admin)]
)
def update_note( 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: ) -> 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) fields = body.model_dump(exclude_unset=True)
if "project_id" in fields: if "project_id" in fields and fields["project_id"] != note.get("project_id"):
_project_or_400(conn, fields["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) return repo.update_memory_note(conn, note_id, **fields)
@router.delete("/notes/{note_id}", dependencies=[Depends(require_admin)]) @router.delete("/notes/{note_id}")
def delete_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_note(
note = _note_or_404(conn, note_id) 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) repo.delete_memory_note(conn, note_id)
return {"deleted": note["id"]} return {"deleted": note["id"]}
@@ -111,20 +152,35 @@ def delete_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict:
"/links", "/links",
response_model=MemoryLinkOut, response_model=MemoryLinkOut,
status_code=status.HTTP_201_CREATED, 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: if body.src_note_id == body.dst_note_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="a note cannot link to itself") raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="a note cannot link to itself")
_note_or_404(conn, body.src_note_id) src = _note_or_404(conn, body.src_note_id, actor)
_note_or_404(conn, body.dst_note_id) 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( return repo.create_memory_link(
conn, body.src_note_id, body.dst_note_id, relation=body.relation, agent_id=None conn, body.src_note_id, body.dst_note_id, relation=body.relation, agent_id=None
) )
@router.delete("/links/{link_id}", dependencies=[Depends(require_admin)]) @router.delete("/links/{link_id}")
def delete_link(link_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_link(
if not repo.delete_memory_link(conn, link_id): 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") 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} return {"deleted": link_id}
+63 -36
View File
@@ -1,8 +1,9 @@
"""Project CRUD + project-scoped control actions. """Project CRUD + project-scoped control actions.
Reads and row registration take the normal token; edits/deletes and the enqueue actions Every route resolves through the ownership rules in ``routes.common``: users see shared
(forge-init, poll-ci) take the admin token. The agent *process* work (spawn/kill) lives in projects plus their own, admins (and legacy tokens) see everything, and mutations plus
``agents.py``; here we cover the project itself and the two project-wide control actions. 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 from __future__ import annotations
@@ -16,27 +17,27 @@ from sqlalchemy.exc import IntegrityError
from ...config import get_settings from ...config import get_settings
from ...db import repository as repo 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 ..schemas import CommandOut, ProjectCreatedOut, ProjectIn, ProjectOut, ProjectUpdateIn
from .common import resolve_project
router = APIRouter(prefix="/projects", tags=["projects"], dependencies=[Depends(require_auth)]) 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]) @router.get("", response_model=list[ProjectOut])
def list_projects(conn: Connection = Depends(db_conn)) -> list[dict]: def list_projects(
return repo.list_projects(conn) 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) @router.get("/{project_id}", response_model=ProjectOut)
def get_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: def get_project(
return _get_or_404(conn, project_id) 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: 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) @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: if body.git_server:
project_id, root_dir, git_remote = _from_git_server(body, conn) project_id, root_dir, git_remote = _from_git_server(body, conn)
else: else:
@@ -88,6 +93,8 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict
root_dir=root_dir, root_dir=root_dir,
git_remote=git_remote, git_remote=git_remote,
credential_ref=body.credential_ref, 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 except IntegrityError as exc: # pragma: no cover - guarded above
raise HTTPException(status.HTTP_409_CONFLICT, detail="project exists") from exc 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 sync_command_id = None
if git_remote: if git_remote:
command = repo.enqueue_command( 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"] 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 mise_init_command_id = None
if body.init_mise and git_remote: if body.init_mise and git_remote:
mise_command = repo.enqueue_command( 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"] 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( 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: ) -> dict:
_get_or_404(conn, project_id) resolve_project(conn, project_id, actor, edit=True)
fields = body.model_dump(exclude_unset=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) return repo.update_project(conn, project_id, **fields)
@router.delete("/{project_id}", dependencies=[Depends(require_admin)]) @router.delete("/{project_id}")
def delete_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: def delete_project(
_get_or_404(conn, project_id) 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) repo.delete_project(conn, project_id)
return {"deleted": 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", "/{project_id}/forge-init",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_forge_init( 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: ) -> dict:
_get_or_404(conn, project_id) resolve_project(conn, project_id, actor, edit=True)
return repo.enqueue_command( return repo.enqueue_command(
conn, conn,
"forge_init", "forge_init",
project_id=project_id, project_id=project_id,
payload={"no_commit": no_commit}, payload={"no_commit": no_commit},
requested_by="operator:web", requested_by=actor.label,
) )
@@ -157,18 +178,21 @@ def enqueue_forge_init(
"/{project_id}/sync", "/{project_id}/sync",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, 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).""" """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"): if not project.get("git_remote"):
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,
detail=f"project '{project_id}' has no git_remote to sync from", detail=f"project '{project_id}' has no git_remote to sync from",
) )
return repo.enqueue_command( 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", "/{project_id}/poll-ci",
response_model=CommandOut, response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
) )
def enqueue_poll_ci(project_id: str, conn: Connection = Depends(db_conn)) -> dict: def enqueue_poll_ci(
_get_or_404(conn, project_id) 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( 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
) )
+44 -23
View File
@@ -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 "Read @notes.md, continue from there, and overwrite that file before finishing", where
the file in the repo carries the state between runs. the file in the repo carries the state between runs.
Reads take the normal token; writes take the admin token (a schedule ultimately runs Schedules belong to their project, so visibility and edit rights follow the project's
``claude`` in the control container). owner (a schedule ultimately runs ``claude`` against that project).
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,8 +18,9 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection from sqlalchemy import Connection
from ...db import repository as repo 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 ..schemas import ScheduleIn, ScheduleOut, ScheduleUpdateIn
from .common import resolve_project, visible_project_ids
router = APIRouter(tags=["schedules"], dependencies=[Depends(require_auth)]) router = APIRouter(tags=["schedules"], dependencies=[Depends(require_auth)])
@@ -33,13 +34,14 @@ def _schedule_or_404(conn: Connection, schedule_id: int) -> dict:
return schedule 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 """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: if model_id is None:
return return
model = repo.get_claude_model(conn, model_id) 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") raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=f"model {model_id} not found")
if not model["enabled"]: if not model["enabled"]:
raise HTTPException( raise HTTPException(
@@ -48,12 +50,24 @@ def _check_model(conn: Connection, model_id: int | None) -> None:
@router.get("/schedules", response_model=list[ScheduleOut]) @router.get("/schedules", response_model=list[ScheduleOut])
def list_all_schedules(conn: Connection = Depends(db_conn)) -> list[dict]: def list_all_schedules(
return repo.list_schedules(conn) 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]) @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) 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", "/projects/{project_id}/schedules",
response_model=ScheduleOut, response_model=ScheduleOut,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
) )
def create_schedule( 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: ) -> dict:
if repo.get_project(conn, project_id) is None: resolve_project(conn, project_id, actor, edit=True)
raise HTTPException( _check_model(conn, body.model_id, actor)
status.HTTP_404_NOT_FOUND, detail=f"project '{project_id}' not found"
)
_check_model(conn, body.model_id)
# next_run_at starts at now, so the first run fires on the worker's next pass — the # 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. # operator sees the schedule work immediately instead of waiting a full interval.
return repo.create_schedule( return repo.create_schedule(
@@ -91,20 +104,28 @@ def create_schedule(
@router.patch( @router.patch(
"/schedules/{schedule_id}", "/schedules/{schedule_id}",
response_model=ScheduleOut, response_model=ScheduleOut,
dependencies=[Depends(require_admin)],
) )
def update_schedule( 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: ) -> 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) fields = body.model_dump(exclude_unset=True)
if fields.get("model_id") is not None: 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) return repo.update_schedule(conn, schedule_id, **fields)
@router.delete("/schedules/{schedule_id}", dependencies=[Depends(require_admin)]) @router.delete("/schedules/{schedule_id}")
def delete_schedule(schedule_id: int, conn: Connection = Depends(db_conn)) -> dict: def delete_schedule(
_schedule_or_404(conn, schedule_id) 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) repo.delete_schedule(conn, schedule_id)
return {"deleted": schedule_id} return {"deleted": schedule_id}
+2
View File
@@ -90,6 +90,8 @@ class ProjectUpdateIn(BaseModel):
root_dir: str | None = None root_dir: str | None = None
git_remote: str | None = None git_remote: str | None = None
credential_ref: 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") @field_validator("credential_ref")
@classmethod @classmethod
+3 -1
View File
@@ -388,7 +388,9 @@ def test_skill_install_route_enqueues(client, auth, lowpriv):
assert r.status_code == 202 assert r.status_code == 202
body = r.json() body = r.json()
assert body["type"] == "skill_install" and body["status"] == "queued" 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 ( assert (
client.post("/claude/skills/install", json={"prompt": "x"}, headers=lowpriv).status_code client.post("/claude/skills/install", json={"prompt": "x"}, headers=lowpriv).status_code