This commit is contained in:
2026-07-07 15:28:28 +02:00
parent 499f91e16a
commit 32c8bbe0a9
52 changed files with 3420 additions and 328 deletions
+54 -9
View File
@@ -3,7 +3,7 @@
from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.database import (
db,
@@ -12,7 +12,11 @@ from devplacepy.database import (
resolve_by_slug,
search_users_by_username,
)
from devplacepy.content import can_view_project
from devplacepy.content import (
can_manage_instance,
can_view_instance,
can_view_project_containers,
)
from devplacepy.models import ContainerAdminCreateForm, ContainerEditForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import (
@@ -44,11 +48,18 @@ def _decorate(instances: list, viewer: dict | None = None) -> list:
decorated = []
for inst in instances:
project = index.get(inst["project_uid"], {})
if viewer is not None and project and not can_view_project(project, viewer):
continue
if viewer is None:
if not project or project.get("is_private"):
continue
manageable = False
else:
if not can_view_instance(inst, project or None, viewer):
continue
manageable = can_manage_instance(inst, project or None, viewer)
row = dict(inst)
row["project_title"] = project.get("title", "")
row["project_slug"] = project.get("slug") or project.get("uid") or ""
row["can_manage"] = manageable
decorated.append(row)
decorated.sort(key=lambda r: r.get("created_at", ""), reverse=True)
return decorated
@@ -65,11 +76,28 @@ def _project_of(inst: dict) -> dict:
def _viewable_instance_or_404(uid: str, viewer: dict) -> dict:
inst = _instance_or_404(uid)
project = _project_of(inst)
if project and not can_view_project(project, viewer):
if not can_view_instance(inst, project or None, viewer):
raise not_found("Instance not found")
return inst
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None) -> None:
def _manage_denied(
request: Request, admin: dict, inst: dict, event_key: str
) -> JSONResponse | None:
project = _project_of(inst)
if can_manage_instance(inst, project or None, admin):
return None
_audit_admin(
request,
admin,
event_key,
inst,
f"admin {admin['username']} denied {event_key} on instance {inst.get('name')}",
result="denied",
)
return json_error(403, "Only the container owner or the primary administrator can manage this instance")
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None, result: str = "success") -> None:
audit.record(
request,
event_key,
@@ -80,6 +108,7 @@ def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summ
metadata=metadata,
summary=summary,
links=[audit.instance(inst["uid"], inst.get("name"))],
result=result,
)
@router.get("", response_class=HTMLResponse)
@@ -133,7 +162,7 @@ async def project_search(request: Request, q: str = ""):
results = [
{"uid": r["uid"], "slug": r["slug"] or r["uid"], "title": r["title"]}
for r in rows
if can_view_project(r, admin)
if can_view_project_containers(r, admin)
][:10]
return JSONResponse({"results": results})
@@ -150,7 +179,7 @@ async def container_create(
):
admin = require_admin(request)
project = resolve_by_slug(get_table("projects"), data.project_slug)
if not project or not can_view_project(project, admin):
if not project or not can_view_project_containers(project, admin):
return json_error(404, "project not found")
try:
inst = await api.create_instance(
@@ -194,6 +223,8 @@ async def container_edit_page(request: Request, uid: str):
admin = require_admin(request)
inst = _viewable_instance_or_404(uid, admin)
project = _project_of(inst)
if not can_manage_instance(inst, project or None, admin):
return RedirectResponse(url=f"/admin/containers/{uid}", status_code=302)
run_as_user = None
if inst.get("run_as_uid"):
run_as_user = get_users_by_uids([inst["run_as_uid"]]).get(inst["run_as_uid"])
@@ -235,6 +266,9 @@ async def container_edit(
):
admin = require_admin(request)
inst = _viewable_instance_or_404(uid, admin)
denied = _manage_denied(request, admin, inst, "container.instance.configure")
if denied:
return denied
try:
updated = api.update_instance_config(
inst,
@@ -282,8 +316,11 @@ _ACTIONS = {
async def container_action(request: Request, uid: str):
admin = require_admin(request)
inst = _viewable_instance_or_404(uid, admin)
actor = ("user", admin["uid"])
action = request.url.path.rsplit("/", 1)[-1]
denied = _manage_denied(request, admin, inst, f"container.instance.{action}")
if denied:
return denied
actor = ("user", admin["uid"])
if action == "restart":
api.request_restart(inst, actor=actor)
else:
@@ -303,6 +340,9 @@ async def container_action(request: Request, uid: str):
async def container_sync(request: Request, uid: str):
admin = require_admin(request)
inst = _viewable_instance_or_404(uid, admin)
denied = _manage_denied(request, admin, inst, "container.instance.sync")
if denied:
return denied
try:
counts = await api.sync_workspace(inst, admin)
except ContainerError as exc:
@@ -321,6 +361,9 @@ async def container_sync(request: Request, uid: str):
async def container_delete(request: Request, uid: str):
admin = require_admin(request)
inst = _viewable_instance_or_404(uid, admin)
denied = _manage_denied(request, admin, inst, "container.instance.delete")
if denied:
return denied
api.mark_for_removal(inst, actor=("user", admin["uid"]))
_audit_admin(
request,
@@ -337,6 +380,7 @@ async def container_instance_page(request: Request, uid: str):
inst = _viewable_instance_or_404(uid, admin)
project = _project_of(inst)
project_slug = project.get("slug") or project.get("uid") or ""
can_manage = can_manage_instance(inst, project or None, admin)
base = site_url(request)
seo_ctx = base_seo_context(
request,
@@ -366,6 +410,7 @@ async def container_instance_page(request: Request, uid: str):
"schedules": store.list_schedules(inst["uid"]),
"stats": api.instance_stats(inst["uid"]),
"runtime": api.instance_runtime(inst),
"can_manage": can_manage,
"admin_section": "containers",
},
model=AdminContainerInstanceOut,
@@ -6,7 +6,7 @@ from fastapi import Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.content import can_view_project
from devplacepy.content import can_manage_instance, can_view_project_containers
from devplacepy.responses import json_error
from devplacepy.utils import not_found
from devplacepy.services.containers import store
@@ -22,6 +22,7 @@ def audit_instance(
project: dict | None = None,
summary: str | None = None,
metadata: Any = None,
result: str | None = None,
) -> None:
links = [audit.instance(inst["uid"], inst.get("name"))]
if project:
@@ -36,6 +37,7 @@ def audit_instance(
metadata=metadata,
summary=summary or f"{user['username']} {event_key} instance {inst.get('name')}",
links=links,
result=result or "success",
)
@@ -43,11 +45,28 @@ def project_for(project_slug: str, user: dict | None = None) -> dict:
project = resolve_by_slug(get_table("projects"), project_slug)
if not project:
raise not_found("Project not found")
if user is not None and not can_view_project(project, user):
if user is not None and not can_view_project_containers(project, user):
raise not_found("Project not found")
return project
def manage_guard(
request: Request, user: dict, project: dict, inst: dict, event_key: str
) -> JSONResponse | None:
if can_manage_instance(inst, project, user):
return None
audit_instance(
request,
user,
event_key,
inst,
project,
summary=f"admin {user['username']} denied {event_key} on instance {inst.get('name')}",
result="denied",
)
return json_error(403, "Only the container owner or the primary administrator can manage this instance")
def slug_of(project: dict) -> str:
return project["slug"] or project["uid"]
@@ -16,7 +16,7 @@ from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.content import can_view_project
from devplacepy.content import can_manage_instance, can_view_project_containers
from devplacepy.models import ContainerExecForm, ContainerInstanceForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import ContainersOut
@@ -34,6 +34,7 @@ from devplacepy.routers.projects.containers._shared import (
audit_instance,
fail,
instance_for,
manage_guard,
project_for,
slug_of,
)
@@ -148,6 +149,9 @@ async def delete_instance(request: Request, project_slug: str, uid: str):
user = require_admin(request)
project = project_for(project_slug, user)
inst = instance_for(project, uid)
denied = manage_guard(request, user, project, inst, "container.instance.delete")
if denied:
return denied
api.mark_for_removal(inst, actor=("user", user["uid"]))
audit_instance(
request, user, "container.instance.delete", inst, project,
@@ -167,6 +171,9 @@ async def instance_exec(
user = require_admin(request)
project = project_for(project_slug, user)
inst = instance_for(project, uid)
denied = manage_guard(request, user, project, inst, "container.instance.exec")
if denied:
return denied
if not inst.get("container_id"):
return json_error(400, "instance is not running")
result = await get_backend().exec(
@@ -217,6 +224,9 @@ async def instance_sync(request: Request, project_slug: str, uid: str):
user = require_admin(request)
project = project_for(project_slug, user)
inst = instance_for(project, uid)
denied = manage_guard(request, user, project, inst, "container.instance.sync")
if denied:
return denied
try:
counts = await api.sync_workspace(inst, user)
except ContainerError as exc:
@@ -239,8 +249,11 @@ async def instance_action(request: Request, project_slug: str, uid: str):
user = require_admin(request)
project = project_for(project_slug, user)
inst = instance_for(project, uid)
actor = ("user", user["uid"])
action = request.url.path.rsplit("/", 1)[-1]
denied = manage_guard(request, user, project, inst, f"container.instance.{action}")
if denied:
return denied
actor = ("user", user["uid"])
if action == "restart":
api.request_restart(inst, actor=actor)
else:
@@ -266,7 +279,7 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
await websocket.close(code=1013)
return
project = resolve_by_slug(get_table("projects"), project_slug)
if not project or not can_view_project(project, user):
if not project or not can_view_project_containers(project, user):
await websocket.close(code=1011)
return
inst = store.get_instance(uid)
@@ -277,6 +290,20 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
):
await websocket.close(code=1011)
return
if not can_manage_instance(inst, project, user):
audit.record(
websocket,
"container.instance.shell.open",
user=user,
target_type="instance",
target_uid=inst["uid"],
target_label=inst.get("name"),
summary=f"admin {user['username']} denied shell access on instance {inst.get('name')}",
result="denied",
links=[audit.instance(inst["uid"], inst.get("name"))],
)
await websocket.close(code=1008)
return
if inst.get("status") != "running":
await websocket.send_text(
"\r\n[devplace] This container is "
@@ -16,6 +16,7 @@ from devplacepy.utils import require_admin
from devplacepy.dependencies import json_or_form
from devplacepy.routers.projects.containers._shared import (
instance_for,
manage_guard,
project_for,
slug_of,
)
@@ -33,6 +34,9 @@ async def create_schedule(
user = require_admin(request)
project = project_for(project_slug, user)
inst = instance_for(project, uid)
denied = manage_guard(request, user, project, inst, "container.schedule.create")
if denied:
return denied
try:
schedule = Schedule(
kind=data.kind,
@@ -65,6 +69,9 @@ async def delete_schedule(request: Request, project_slug: str, uid: str, sid: st
user = require_admin(request)
project = project_for(project_slug, user)
inst = instance_for(project, uid)
denied = manage_guard(request, user, project, inst, "container.schedule.delete")
if denied:
return denied
store.delete_schedule(sid)
audit.record(
request,
+2
View File
@@ -34,6 +34,7 @@ from devplacepy.content import (
first_image_url,
is_owner,
can_view_project,
can_view_project_containers,
)
from devplacepy.utils import (
get_current_user,
@@ -230,6 +231,7 @@ async def project_detail(request: Request, project_slug: str):
else [],
"is_private": bool(project.get("is_private")),
"read_only": bool(project.get("read_only")),
"viewer_can_containers": can_view_project_containers(project, user),
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),
+27 -10
View File
@@ -206,31 +206,42 @@ async def deepsearch_status(request: Request, uid: str):
DeepsearchJobOut.model_validate(_job_payload(job)).model_dump(mode="json")
)
def _report_for(job: dict) -> dict:
if job.get("status") != queue.DONE:
def _report_from_disk(uid: str) -> dict:
path = DEEPSEARCH_DIR / uid / "report.json"
try:
return json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return {}
return job.get("result", {}).get("report", {})
def _report_for(uid: str, job: dict) -> dict:
if job.get("status") == queue.DONE:
report = job.get("result", {}).get("report", {})
if report:
return report
if job.get("status") == queue.FAILED:
return {}
return _report_from_disk(uid)
def _session_context(request: Request, uid: str, job: dict, session: dict) -> dict:
report = _report_for(job)
report = _report_for(uid, job)
user = get_current_user(request)
viewer_is_admin = is_admin(user)
done = job.get("status") == queue.DONE
done = bool(report) or job.get("status") == queue.DONE
return {
"uid": uid,
"status": job.get("status", ""),
"status": queue.DONE if done else job.get("status", ""),
"query": report.get("query") or session.get("query"),
"depth": int(session.get("depth") or 0),
"max_pages": int(session.get("max_pages") or 0),
"score": report.get("score"),
"confidence": report.get("confidence"),
"source_diversity": report.get("source_diversity"),
"synthesis": report.get("synthesis", ""),
"page_count": report.get("page_count", 0),
"chunk_count": report.get("chunk_count", 0),
"summary": report.get("summary", ""),
"sources": report.get("sources", []),
"findings": report.get("findings", []),
"gaps": report.get("gaps", []),
"timeline": report.get("timeline", []),
"chat_ws_url": f"/tools/deepsearch/{uid}/chat" if done else None,
"export_md_url": f"/tools/deepsearch/{uid}/export.md" if done else None,
@@ -282,10 +293,13 @@ def _control(request: Request, uid: str, state: str):
def _export_report(uid: str) -> dict | None:
job = queue.get_job(uid)
if not job or job.get("kind") != "deepsearch" or job.get("status") != queue.DONE:
if not job or job.get("kind") != "deepsearch" or job.get("status") == queue.FAILED:
return None
report = _report_for(uid, job)
if not report:
return None
queue.touch_job(uid, TOUCH_EXTEND_SECONDS)
return job.get("result", {}).get("report", {})
return report
@router.get("/{uid}/export.md")
async def deepsearch_export_md(request: Request, uid: str):
@@ -354,7 +368,10 @@ async def deepsearch_chat_ws(websocket: WebSocket, uid: str):
return
job = queue.get_job(uid)
session = database.get_deepsearch_session(uid)
if not job or job.get("kind") != "deepsearch" or job.get("status") != queue.DONE or not session:
ready = job and (
job.get("status") == queue.DONE or (session or {}).get("status") == "done"
)
if not job or job.get("kind") != "deepsearch" or not ready or not session:
await websocket.close(code=1008)
return
user = get_current_user(websocket)