mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-17 06:14:25 +00:00
Roles audit and revamp
This commit is contained in:
1 parent
208d34abc7
commit
98afa5d93c
134 files changed
+13725
-1702
No files matched your search
@@ -1,4 +1,11 @@
|
||||
"""Schedules REST API (owner-scoped via request.decoded_token)."""
|
||||
"""Schedules REST API.
|
||||
|
||||
A schedule on an agent belongs to the agent's owner: it is stored (and runs)
|
||||
under the owner's ``user_id`` whoever creates it, and managing it needs
|
||||
``manage_schedules`` on the agent (owner and team editors). A schedule the
|
||||
caller made themselves (e.g. via the chat scheduler tool on a shared agent)
|
||||
stays theirs to manage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,6 +27,7 @@ from docsgpt.agents.scheduler_utils import (
|
||||
resolve_timezone,
|
||||
)
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -103,11 +111,70 @@ def _format_run(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _agent_owned(agent_id: str, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _agent_for_schedules(agent_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
|
||||
"""The agent row and the id its schedules live under.
|
||||
|
||||
Args:
|
||||
agent_id: Agent id from the URL.
|
||||
user_id: The caller.
|
||||
|
||||
Returns:
|
||||
``(agent, owner_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when the agent isn't visible, 403 without
|
||||
``manage_schedules``.
|
||||
"""
|
||||
if not looks_like_uuid(str(agent_id)):
|
||||
return None
|
||||
raise AccessDenied(404, "agent not found")
|
||||
with db_readonly() as conn:
|
||||
return AgentsRepository(conn).get_any(agent_id, user_id)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user_id, "manage_schedules")
|
||||
except AccessDenied as denied:
|
||||
if denied.status == 404:
|
||||
raise AccessDenied(404, "agent not found")
|
||||
raise
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if agent is None:
|
||||
raise AccessDenied(404, "agent not found")
|
||||
return agent, ra.owner_id
|
||||
|
||||
|
||||
def _schedule_for(conn, schedule_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Fetch a schedule the caller may manage, and the id to act as.
|
||||
|
||||
The caller's own schedule is always theirs. Otherwise it must be a
|
||||
schedule of an agent they hold ``manage_schedules`` on, stored under
|
||||
that agent's owner (another member's private schedule stays hidden).
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
schedule_id: Schedule UUID.
|
||||
user_id: The caller.
|
||||
|
||||
Returns:
|
||||
``(schedule, acting_user_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't manage it.
|
||||
"""
|
||||
row = SchedulesRepository(conn).get_internal(schedule_id)
|
||||
if row is None:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
if row.get("user_id") == user_id:
|
||||
return row, user_id
|
||||
agent_id = row.get("agent_id")
|
||||
if not agent_id:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
try:
|
||||
ra = require(conn, "agent", str(agent_id), user_id, "manage_schedules")
|
||||
except AccessDenied as denied:
|
||||
if denied.status == 404:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
raise
|
||||
if row.get("user_id") != ra.owner_id:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
return row, ra.owner_id
|
||||
|
||||
|
||||
def _user_id() -> Optional[str]:
|
||||
@@ -150,13 +217,14 @@ class AgentSchedules(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
rows = SchedulesRepository(conn).list_for_agent(
|
||||
str(agent["id"]), user_id,
|
||||
str(agent["id"]), owner_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error("list schedules failed: %s", exc, exc_info=True)
|
||||
@@ -195,9 +263,10 @@ class AgentSchedules(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
data = request.get_json(silent=True) or {}
|
||||
instruction = (data.get("instruction") or "").strip()
|
||||
tz_name = (data.get("timezone") or "UTC").strip() or "UTC"
|
||||
@@ -219,7 +288,7 @@ class AgentSchedules(Resource):
|
||||
except (TypeError, ValueError):
|
||||
return _err("token_budget must be a non-negative integer")
|
||||
with db_readonly() as conn:
|
||||
count = SchedulesRepository(conn).count_active_for_user(user_id)
|
||||
count = SchedulesRepository(conn).count_active_for_user(owner_id)
|
||||
if (
|
||||
settings.SCHEDULE_MAX_PER_USER > 0
|
||||
and count >= settings.SCHEDULE_MAX_PER_USER
|
||||
@@ -240,7 +309,7 @@ class AgentSchedules(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
created = SchedulesRepository(conn).create(
|
||||
user_id=user_id,
|
||||
user_id=owner_id,
|
||||
agent_id=str(agent["id"]),
|
||||
trigger_type="once",
|
||||
instruction=instruction,
|
||||
@@ -295,7 +364,7 @@ class AgentSchedules(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
created = SchedulesRepository(conn).create(
|
||||
user_id=user_id,
|
||||
user_id=owner_id,
|
||||
agent_id=str(agent["id"]),
|
||||
trigger_type="recurring",
|
||||
instruction=instruction,
|
||||
@@ -337,9 +406,10 @@ class AgentScheduleStats(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
try:
|
||||
days = max(1, min(int(request.args.get("days", 30)), 365))
|
||||
except (TypeError, ValueError):
|
||||
@@ -347,7 +417,7 @@ class AgentScheduleStats(Resource):
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
stats = ScheduleRunsRepository(conn).stats_for_agent(
|
||||
str(agent["id"]), user_id, days=days,
|
||||
str(agent["id"]), owner_id, days=days,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error(
|
||||
@@ -377,9 +447,10 @@ class ScheduleResource(Resource):
|
||||
if not looks_like_uuid(schedule_id):
|
||||
return _err("invalid schedule id", 400)
|
||||
with db_readonly() as conn:
|
||||
row = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if row is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
row, _acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
return _ok({"schedule": _format_schedule(row)})
|
||||
|
||||
@api.doc(description="Edit a schedule's editable fields.")
|
||||
@@ -439,9 +510,10 @@ class ScheduleResource(Resource):
|
||||
fields_in["end_at"] = None
|
||||
# Recompute next_run_at when cron/tz changes.
|
||||
with db_session() as conn:
|
||||
existing = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if existing is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
existing, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
if (
|
||||
("cron" in fields_in or "timezone" in fields_in)
|
||||
and existing.get("trigger_type") == "recurring"
|
||||
@@ -467,7 +539,7 @@ class ScheduleResource(Resource):
|
||||
except ScheduleValidationError as exc:
|
||||
return _err(str(exc))
|
||||
updated = SchedulesRepository(conn).update(
|
||||
schedule_id, user_id, fields_in,
|
||||
schedule_id, acting, fields_in,
|
||||
)
|
||||
return _ok({"schedule": _format_schedule(updated or {})})
|
||||
|
||||
@@ -484,9 +556,10 @@ class ScheduleResource(Resource):
|
||||
if action not in {"pause", "resume"}:
|
||||
return _err("action must be 'pause' or 'resume'")
|
||||
with db_session() as conn:
|
||||
existing = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if existing is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
existing, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
if existing.get("status") in ("cancelled", "completed"):
|
||||
return _err("schedule is terminal", 409)
|
||||
if action == "pause":
|
||||
@@ -538,13 +611,13 @@ class ScheduleResource(Resource):
|
||||
)
|
||||
fields_in["next_run_at"] = run_at_dt
|
||||
updated = SchedulesRepository(conn).update(
|
||||
schedule_id, user_id, fields_in,
|
||||
schedule_id, acting, fields_in,
|
||||
)
|
||||
if action == "resume":
|
||||
SchedulesRepository(conn).reset_failure_count(schedule_id)
|
||||
if action == "resume" and updated:
|
||||
_publish_schedule_event(
|
||||
user_id, "schedule.resumed", schedule_id, status="active",
|
||||
acting, "schedule.resumed", schedule_id, status="active",
|
||||
)
|
||||
return _ok({"schedule": _format_schedule(updated or {})})
|
||||
|
||||
@@ -557,11 +630,15 @@ class ScheduleResource(Resource):
|
||||
if not looks_like_uuid(schedule_id):
|
||||
return _err("invalid schedule id", 400)
|
||||
with db_session() as conn:
|
||||
ok = SchedulesRepository(conn).delete(schedule_id, user_id)
|
||||
try:
|
||||
_row, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
ok = SchedulesRepository(conn).delete(schedule_id, acting)
|
||||
if not ok:
|
||||
return _err("schedule not found", 404)
|
||||
_publish_schedule_event(
|
||||
user_id, "schedule.cancelled", schedule_id, status="cancelled",
|
||||
acting, "schedule.cancelled", schedule_id, status="cancelled",
|
||||
)
|
||||
return _ok({"success": True})
|
||||
|
||||
@@ -579,8 +656,12 @@ class ScheduleRunNow(Resource):
|
||||
# FOR UPDATE serializes concurrent Run-Now POSTs (timestamp-unique
|
||||
# scheduled_for values would otherwise sneak past the unique index).
|
||||
with db_session() as conn:
|
||||
try:
|
||||
_row, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
schedule = SchedulesRepository(conn).get_for_update(
|
||||
schedule_id, user_id,
|
||||
schedule_id, acting,
|
||||
)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
@@ -590,9 +671,10 @@ class ScheduleRunNow(Resource):
|
||||
return _err("a run is already in flight", 409)
|
||||
scheduled_for = datetime.now(timezone.utc)
|
||||
agent_id_raw = schedule.get("agent_id")
|
||||
# The run belongs to (and executes as) the schedule's owner.
|
||||
run = ScheduleRunsRepository(conn).record_pending(
|
||||
schedule_id,
|
||||
user_id,
|
||||
acting,
|
||||
str(agent_id_raw) if agent_id_raw else None,
|
||||
scheduled_for,
|
||||
trigger_source="manual",
|
||||
@@ -633,11 +715,12 @@ class ScheduleRunList(Resource):
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
with db_readonly() as conn:
|
||||
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
_schedule, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
rows = ScheduleRunsRepository(conn).list_runs(
|
||||
schedule_id, user_id, limit=limit, offset=offset,
|
||||
schedule_id, acting, limit=limit, offset=offset,
|
||||
)
|
||||
return _ok(
|
||||
{
|
||||
@@ -659,10 +742,11 @@ class ScheduleRunDetail(Resource):
|
||||
if not looks_like_uuid(schedule_id) or not looks_like_uuid(run_id):
|
||||
return _err("invalid id", 400)
|
||||
with db_readonly() as conn:
|
||||
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
run = ScheduleRunsRepository(conn).get(run_id, user_id)
|
||||
try:
|
||||
schedule, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
run = ScheduleRunsRepository(conn).get(run_id, acting)
|
||||
if run is None or str(run.get("schedule_id")) != str(
|
||||
schedule["id"]
|
||||
):
|
||||
|
||||
Reference in new issue
Block a user