forked from retoor/devplacepy
feat: restrict backup archive download to primary admin and hide admin-hidden projects from other admins
- Add `get_admin_uids()` and `get_primary_admin_uid()` to database.py for resolving the earliest-created admin - Modify `can_view_project()` in content.py so a project hidden by an admin is invisible to other admins (both web UI and REST API) - Update `_download_url()` and `_backup_payload()` in admin/backups.py to accept a `can_download` flag, gating the download endpoint with `is_primary_admin()` - Remove `role` from `_user_facts()` in docs_live.py to avoid leaking admin status in live docs - Update doc summaries in docs_api.py to reflect the new admin-visibility and backup-download semantics
This commit is contained in:
@@ -1593,8 +1593,8 @@ ACTIONS: tuple[Action, ...] = (
|
||||
summary="Run a read-only SQL SELECT and return rows (admin only)",
|
||||
description=(
|
||||
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected (use db_insert_row, "
|
||||
"db_update_row, db_delete_row for changes). The response may include a 'suspicious' "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected. The database API is "
|
||||
"read-only and cannot change data in any way. The response may include a 'suspicious' "
|
||||
"list (e.g. a SELECT with no WHERE/JOIN/LIMIT that scans a whole table); when present, "
|
||||
"surface that warning to the user before trusting the results."
|
||||
),
|
||||
@@ -1638,64 +1638,6 @@ ACTIONS: tuple[Action, ...] = (
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="db_insert_row",
|
||||
method="POST",
|
||||
path="/dbapi/{table}",
|
||||
summary="Insert a row into a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Inserts a new row. Pass the column values as a JSON object string in values_json. "
|
||||
"Soft-delete columns and uid/created_at are filled automatically. Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
body("values_json", "JSON object of column:value pairs for the new row.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_update_row",
|
||||
method="PATCH",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Update a row in a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Updates the row where key equals value. Pass the changed columns as a JSON object "
|
||||
"string in values_json. uid and id cannot be changed. Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
path("key", "Key column to match (usually 'uid')."),
|
||||
path("value", "Value of the key column."),
|
||||
body("values_json", "JSON object of column:value pairs to change.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_delete_row",
|
||||
method="DELETE",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Delete a row from a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Soft-deletes the row where key equals value (restorable). Pass hard=true to "
|
||||
"PERMANENTLY purge it (or for tables without soft delete). Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
path("key", "Key column to match (usually 'uid')."),
|
||||
path("value", "Value of the key column."),
|
||||
Param(
|
||||
name="hard",
|
||||
location="query",
|
||||
description="Permanently purge instead of soft delete.",
|
||||
required=False,
|
||||
type="boolean",
|
||||
),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="gateway_providers",
|
||||
method="GET",
|
||||
|
||||
@@ -53,9 +53,6 @@ CONFIRM_REQUIRED = {
|
||||
"backup_delete",
|
||||
"backup_schedule_delete",
|
||||
"notification_reset",
|
||||
"db_insert_row",
|
||||
"db_update_row",
|
||||
"db_delete_row",
|
||||
"gateway_provider_delete",
|
||||
"gateway_model_delete",
|
||||
}
|
||||
@@ -219,26 +216,6 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
|
||||
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
|
||||
"explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_insert_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"This writes a new row directly into the '{table}' table. Show the user the exact "
|
||||
"table and values, get explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_update_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"This updates an existing row in the '{table}' table directly. Show the user the "
|
||||
"exact row and new values, get explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_delete_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
hard = str(arguments.get("hard", "")).strip().lower() in ("true", "1", "yes", "on")
|
||||
kind = "PERMANENTLY purges" if hard else "soft-deletes"
|
||||
return ToolInputError(
|
||||
f"This {kind} a row in the '{table}' table. Show the user the exact row, get explicit "
|
||||
"confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name in CONFIRM_REQUIRED:
|
||||
return ToolInputError(
|
||||
"This removes the item as a soft delete: it disappears from every surface and is only "
|
||||
@@ -279,7 +256,7 @@ class Dispatcher:
|
||||
self._docs = DocsController(settings, is_admin=is_admin)
|
||||
self._cost = CostController(quota_provider=quota_provider)
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings)
|
||||
self._rsearch = RsearchController(settings, owner_kind, owner_id)
|
||||
from ..container import ContainerController
|
||||
|
||||
self._container = ContainerController(client)
|
||||
|
||||
@@ -36,9 +36,11 @@ class ContainerController:
|
||||
return {"uid": "admin", "username": username or "admin"}
|
||||
|
||||
def _project(self, arguments: dict) -> dict:
|
||||
from devplacepy.content import can_view_project
|
||||
|
||||
slug = str(arguments.get("project_slug", "")).strip()
|
||||
project = resolve_by_slug(get_table("projects"), slug) if slug else None
|
||||
if not project:
|
||||
if not project or not can_view_project(project, self._actor_user()):
|
||||
raise ToolInputError(f"project not found: {slug}")
|
||||
return project
|
||||
|
||||
|
||||
@@ -26,8 +26,12 @@ def _flag(value: Any) -> str:
|
||||
|
||||
|
||||
class RsearchController:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(
|
||||
self, settings: Settings, owner_kind: str = "guest", owner_id: str = ""
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if not self._settings.rsearch_enabled:
|
||||
@@ -44,6 +48,13 @@ class RsearchController:
|
||||
return await self._describe(arguments)
|
||||
raise ToolInputError(f"Unknown rsearch tool: {name}")
|
||||
|
||||
def _ledger(self, endpoint: str, success: bool, status_code: int) -> None:
|
||||
from devplacepy.services.openai_gateway.usage import record_rsearch_call
|
||||
|
||||
record_rsearch_call(
|
||||
self._owner_kind, self._owner_id, endpoint, success, status_code
|
||||
)
|
||||
|
||||
async def _request(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
timeout = httpx.Timeout(self._settings.rsearch_timeout_seconds, connect=30.0)
|
||||
@@ -56,9 +67,12 @@ class RsearchController:
|
||||
) as client:
|
||||
response = await client.get(path, params=params)
|
||||
except httpx.TimeoutException as exc:
|
||||
self._ledger(path, False, 0)
|
||||
raise NetworkError(f"rsearch timed out calling {path}", path=path) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
self._ledger(path, False, 0)
|
||||
raise NetworkError(f"rsearch request failed: {exc}", path=path) from exc
|
||||
self._ledger(path, response.status_code < 400, response.status_code)
|
||||
if response.status_code >= 400:
|
||||
raise UpstreamError(
|
||||
f"rsearch returned {response.status_code} for {path}",
|
||||
|
||||
Reference in New Issue
Block a user