mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-13 21:21:53 +00:00
Consolidates the two parallel RBAC frameworks into a single route-level
authorization system and adds the missing pieces needed for a managed-onboarding
pilot: an endpoint to list pending invites, sane visibility defaults for
pockets/groups so invited teammates actually see shared work, and audit
logging on every denial.
RBAC consolidation
- New ACTIONS table (src/pocketpaw/ee/guards/actions.py) is the single source
of truth for every guarded action → (minimum role, stable deny code).
Covers workspace, group, message, pocket, agent, session, KB, invite,
and billing — 32 rows total.
- New audit helpers (src/pocketpaw/ee/guards/audit.py): log_denial() +
log_privileged_action() backed by the existing append-only audit log.
- New FastAPI deps: require_action(), require_action_any_workspace(),
require_group_action(), require_membership(), require_agent_owner_or_admin(),
require_pocket_edit(), require_pocket_owner().
- Route-level enforcement across workspace/chat/pockets/agents/sessions/kb
routers; permission checks removed from service bodies where now redundant.
- Group role model extended to 3 tiers (owner > admin > member), with per-member
override via Group.member_roles. Admin tier enforced via resolve_group_role()
and the updated _require_group_admin helper.
- Legacy ee/cloud/shared/permissions.py deleted; legacy require_role shim
removed from ee/cloud/shared/deps.py.
- Matrix test (tests/cloud/test_rbac_matrix.py) iterates every ACTIONS entry
across every peer role, verifying both allow and deny paths and the exact
Forbidden.code. Meta-test enforces coverage.
- Redundant tests/cloud/test_permissions.py removed.
Invite list endpoint
- GET /workspaces/{workspace_id}/invites returns all pending invites,
admin-only via require_action("invite.create"). Wires the admin UX for
listing + copy-link + revoke.
Visibility defaults
- Pocket.visibility default flipped "private" → "workspace" so new pockets
are visible to all workspace members out of the box. Owners can tighten
per-pocket via the Share tab.
- Group.type default flipped "public" → "private" so new groups are
invite-only. Public channels remain explicit via type="channel" or "public".
- CreateGroupRequest and UpdateGroupRequest updated; GroupService.update_group
now supports type changes. list_groups() treats "channel" same as "public"
for workspace-wide readability.
- Existing tests updated to match new defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
263 lines
8.1 KiB
Python
263 lines
8.1 KiB
Python
"""Agents domain — FastAPI router."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request, UploadFile
|
|
from fastapi import File as FastAPIFile
|
|
from starlette.responses import Response
|
|
|
|
from ee.cloud.agents.schemas import (
|
|
CreateAgentRequest,
|
|
DiscoverRequest,
|
|
UpdateAgentRequest,
|
|
)
|
|
from ee.cloud.agents.service import AgentService
|
|
from ee.cloud.license import require_license
|
|
from ee.cloud.shared.deps import (
|
|
current_user_id,
|
|
current_workspace_id,
|
|
require_action_any_workspace,
|
|
require_agent_owner_or_admin,
|
|
)
|
|
|
|
router = APIRouter(prefix="/agents", tags=["Agents"], dependencies=[Depends(require_license)])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backends discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/backends")
|
|
async def list_available_backends():
|
|
"""List available agent backends with their display names."""
|
|
from pocketpaw.agents.registry import get_backend_info, list_backends
|
|
|
|
results = []
|
|
for name in list_backends():
|
|
try:
|
|
info = get_backend_info(name)
|
|
results.append(
|
|
{
|
|
"name": name,
|
|
"displayName": info.display_name if info else name,
|
|
"available": info is not None,
|
|
}
|
|
)
|
|
except Exception:
|
|
results.append({"name": name, "displayName": name, "available": False})
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("", dependencies=[Depends(require_action_any_workspace("agent.create"))])
|
|
async def create_agent(
|
|
body: CreateAgentRequest,
|
|
workspace_id: str = Depends(current_workspace_id),
|
|
user_id: str = Depends(current_user_id),
|
|
) -> dict:
|
|
return await AgentService.create(workspace_id, user_id, body)
|
|
|
|
|
|
@router.get("")
|
|
async def list_agents(
|
|
workspace_id: str = Depends(current_workspace_id),
|
|
query: str | None = Query(default=None),
|
|
) -> list[dict]:
|
|
return await AgentService.list_agents(workspace_id, query)
|
|
|
|
|
|
@router.get("/{agent_id}")
|
|
async def get_agent(agent_id: str) -> dict:
|
|
return await AgentService.get(agent_id)
|
|
|
|
|
|
@router.get("/uname/{slug}")
|
|
async def get_by_slug(
|
|
slug: str,
|
|
workspace_id: str = Depends(current_workspace_id),
|
|
) -> dict:
|
|
return await AgentService.get_by_slug(workspace_id, slug)
|
|
|
|
|
|
@router.patch("/{agent_id}", dependencies=[Depends(require_agent_owner_or_admin)])
|
|
async def update_agent(
|
|
agent_id: str,
|
|
body: UpdateAgentRequest,
|
|
user_id: str = Depends(current_user_id),
|
|
) -> dict:
|
|
return await AgentService.update(agent_id, user_id, body)
|
|
|
|
|
|
@router.delete("/{agent_id}", status_code=204, dependencies=[Depends(require_agent_owner_or_admin)])
|
|
async def delete_agent(
|
|
agent_id: str,
|
|
user_id: str = Depends(current_user_id),
|
|
) -> Response:
|
|
await AgentService.delete(agent_id, user_id)
|
|
return Response(status_code=204)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/discover")
|
|
async def discover_agents(
|
|
body: DiscoverRequest,
|
|
workspace_id: str = Depends(current_workspace_id),
|
|
user_id: str = Depends(current_user_id),
|
|
) -> list[dict]:
|
|
return await AgentService.discover(workspace_id, user_id, body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Knowledge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{agent_id}/knowledge/text")
|
|
async def ingest_text(agent_id: str, body: dict):
|
|
"""Ingest plain text into agent's knowledge base."""
|
|
import logging
|
|
|
|
from ee.cloud.agents.knowledge import KnowledgeService
|
|
|
|
text = body.get("text", "")
|
|
source = body.get("source", "manual")
|
|
if not text:
|
|
return {"error": "No text provided"}
|
|
try:
|
|
return await KnowledgeService.ingest_text(agent_id, text, source)
|
|
except Exception as exc:
|
|
logging.getLogger(__name__).error("Knowledge ingest failed: %s", exc, exc_info=True)
|
|
return {"error": str(exc)}
|
|
|
|
|
|
@router.post("/{agent_id}/knowledge/url")
|
|
async def ingest_url(agent_id: str, body: dict):
|
|
"""Fetch and ingest a URL into agent's knowledge base."""
|
|
from ee.cloud.agents.knowledge import KnowledgeService
|
|
|
|
url = body.get("url", "")
|
|
if not url:
|
|
return {"error": "No URL provided"}
|
|
return await KnowledgeService.ingest_url(agent_id, url)
|
|
|
|
|
|
@router.post("/{agent_id}/knowledge/urls")
|
|
async def ingest_urls(agent_id: str, body: dict):
|
|
"""Batch ingest multiple URLs."""
|
|
from ee.cloud.agents.knowledge import KnowledgeService
|
|
|
|
urls = body.get("urls", [])
|
|
results = []
|
|
for url in urls:
|
|
result = await KnowledgeService.ingest_url(agent_id, url)
|
|
results.append(result)
|
|
return results
|
|
|
|
|
|
@router.get("/{agent_id}/knowledge/search")
|
|
async def search_knowledge(agent_id: str, q: str = Query(..., min_length=1), limit: int = 5):
|
|
"""Search agent's knowledge base."""
|
|
from ee.cloud.agents.knowledge import KnowledgeService
|
|
|
|
results = await KnowledgeService.search(agent_id, q, limit)
|
|
return {"results": results}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Profile Picture Upload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{agent_id}/profile-pic")
|
|
async def upload_profile_pic(
|
|
agent_id: str,
|
|
request: Request,
|
|
file: UploadFile = FastAPIFile(...),
|
|
user_id: str = Depends(current_user_id),
|
|
):
|
|
"""Upload a profile picture for an agent."""
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException
|
|
|
|
if not file.filename:
|
|
raise HTTPException(status_code=400, detail="No filename provided")
|
|
|
|
# Validate file type
|
|
allowed = {"image/jpeg", "image/png", "image/webp"}
|
|
if file.content_type not in allowed:
|
|
raise HTTPException(status_code=400, detail="Only JPEG, PNG, and WebP images are allowed")
|
|
|
|
content = await file.read()
|
|
if len(content) > 5 * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail="File size must be under 5 MB")
|
|
|
|
# Save to ~/.pocketpaw/uploads/avatars/
|
|
ext = Path(file.filename).suffix.lower() or ".png"
|
|
upload_dir = Path.home() / ".pocketpaw" / "uploads" / "avatars"
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
filename = f"{agent_id}-{uuid.uuid4().hex[:8]}{ext}"
|
|
dest = upload_dir / filename
|
|
dest.write_bytes(content)
|
|
|
|
# Build full URL using the request's base URL
|
|
base = str(request.base_url).rstrip("/")
|
|
avatar_url = f"{base}/uploads/avatars/{filename}"
|
|
|
|
# Update the agent's avatar field
|
|
await AgentService.update(agent_id, user_id, UpdateAgentRequest(avatar=avatar_url))
|
|
|
|
return {"url": avatar_url}
|
|
|
|
|
|
@router.post("/{agent_id}/knowledge/upload")
|
|
async def upload_and_ingest(
|
|
agent_id: str,
|
|
file: UploadFile = FastAPIFile(...), # noqa: B008
|
|
):
|
|
"""Upload a file and ingest into agent's knowledge base.
|
|
|
|
Supports: .pdf, .txt, .md, .csv, .json, .docx, .png, .jpg, .jpeg, .webp
|
|
"""
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from ee.cloud.agents.knowledge import KnowledgeService
|
|
|
|
if not file.filename:
|
|
return {"error": "No filename provided"}
|
|
|
|
suffix = Path(file.filename).suffix.lower()
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
content = await file.read()
|
|
tmp.write(content)
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
result = await KnowledgeService.ingest_file(agent_id, tmp_path)
|
|
result["originalName"] = file.filename
|
|
result["size"] = len(content)
|
|
return result
|
|
finally:
|
|
import os
|
|
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
@router.delete("/{agent_id}/knowledge", status_code=204)
|
|
async def clear_knowledge(agent_id: str):
|
|
"""Clear all knowledge for an agent."""
|
|
from ee.cloud.agents.knowledge import KnowledgeService
|
|
|
|
await KnowledgeService.clear(agent_id)
|
|
return Response(status_code=204)
|