Update
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user