Roles audit and revamp

This commit is contained in:
Pavel committed 2026-09-29 10:42:39 +04:00
1 parent 208d34abc7
commit 98afa5d93c
134 files changed
+13725 -1702

No files matched your search

+126 -42
View File
@@ -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"]
):