mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 22:15:10 +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
@@ -8,6 +8,7 @@ from flask_restx import Namespace, Resource, fields
|
||||
from sqlalchemy import text as _sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, payload_for, require, settings_many
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -143,11 +144,16 @@ class AgentFolder(Resource):
|
||||
),
|
||||
{"user_id": user, "fid": pg_folder_id},
|
||||
).fetchall()
|
||||
switches = settings_many(
|
||||
conn, "agent", [str(row._mapping["id"]) for row in agents_rows]
|
||||
)
|
||||
# Folder contents are the caller's own agents.
|
||||
agents_list = [
|
||||
{
|
||||
"id": str(row._mapping["id"]),
|
||||
"name": row._mapping["name"],
|
||||
"description": row._mapping.get("description", "") or "",
|
||||
**payload_for("agent", "owner", switches[str(row._mapping["id"])]),
|
||||
}
|
||||
for row in agents_rows
|
||||
]
|
||||
@@ -298,7 +304,14 @@ class MoveAgentToFolder(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
# Folders are the owner's own organisation of their agents.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id_input, user, "move_folder")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}),
|
||||
@@ -357,10 +370,18 @@ class BulkMoveAgents(Resource):
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
# Only the caller's own agents move (``move_folder`` is
|
||||
# owner-only); anything else is reported back, not dropped.
|
||||
moved, skipped = [], []
|
||||
for agent_id_input in agent_ids:
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
if agent is not None:
|
||||
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
moved.append(str(agent_id_input))
|
||||
else:
|
||||
skipped.append(str(agent_id_input))
|
||||
return make_response(
|
||||
jsonify({"success": True, "moved": moved, "skipped": skipped}), 200
|
||||
)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to move agents", err)
|
||||
@@ -4,7 +4,7 @@ from flask import jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.team_sharing import team_access_for
|
||||
from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.guardrails.checks.patterns import DEFAULT_PII_ENTITIES, PII_PATTERNS
|
||||
from docsgpt.guardrails.config import DEFAULT_BLOCK_MESSAGE, MODES
|
||||
@@ -70,15 +70,30 @@ class GuardrailCatalog(Resource):
|
||||
)
|
||||
|
||||
|
||||
def _readable_agent(conn, agent_id: str, user: str):
|
||||
"""Return the agent row when the caller may read it, else None."""
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if agent:
|
||||
return agent
|
||||
if team_access_for(conn, user, "agent", agent_id):
|
||||
return repo.get_by_id(agent_id)
|
||||
return None
|
||||
def _logs_access(conn, agent_id: str, user: str) -> tuple[dict, ResourceAccess]:
|
||||
"""The agent row and the caller's access, for reading its guardrail journal.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
agent_id: The agent's id (UUID or legacy).
|
||||
user: The caller.
|
||||
|
||||
Returns:
|
||||
``(agent, access)``; rows are read as ``access.owner_id``, so a team
|
||||
member with ``view_logs`` sees exactly what the owner sees.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when the agent isn't visible, 403 without ``view_logs``.
|
||||
"""
|
||||
ra = require(conn, "agent", agent_id, user, "view_logs")
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if agent is None:
|
||||
raise AccessDenied(404, "Agent not found")
|
||||
return agent, ra
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
@agents_guardrails_ns.route("/guardrails/events")
|
||||
@@ -106,17 +121,16 @@ class GuardrailEvents(Resource):
|
||||
400,
|
||||
)
|
||||
with db_readonly() as conn:
|
||||
agent = _readable_agent(conn, agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
try:
|
||||
agent, ra = _logs_access(conn, agent_id, user)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Query on the row's UUID, not the caller's argument: a legacy
|
||||
# 24-hex Mongo id resolves fine above but would blow up the cast.
|
||||
# Rows stay scoped to the requesting user even on a shared agent —
|
||||
# another member's blocked prompts are not this caller's to read.
|
||||
# Rows are the owner's view: ``view_logs`` shows a team member
|
||||
# what the owner sees, never other members' own chats.
|
||||
events = GuardrailEventsRepository(conn).list_for_agent(
|
||||
str(agent["id"]), user, limit=limit, offset=offset
|
||||
str(agent["id"]), ra.owner_id, limit=limit, offset=offset
|
||||
)
|
||||
return make_response(jsonify({"success": True, "events": events}), 200)
|
||||
|
||||
@@ -143,14 +157,15 @@ class GuardrailSummary(Resource):
|
||||
agent_id = request.args.get("agent_id")
|
||||
with db_readonly() as conn:
|
||||
scoped_id = None
|
||||
scope_user = user
|
||||
if agent_id:
|
||||
agent = _readable_agent(conn, agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
try:
|
||||
agent, ra = _logs_access(conn, agent_id, user)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
scoped_id = str(agent["id"])
|
||||
scope_user = ra.owner_id
|
||||
summary = GuardrailEventsRepository(conn).summary_for_user(
|
||||
user, days=days, agent_id=scoped_id
|
||||
scope_user, days=days, agent_id=scoped_id
|
||||
)
|
||||
return make_response(jsonify({"success": True, **summary}), 200)
|
||||
@@ -37,6 +37,7 @@ from docsgpt.agents.default_tools import (
|
||||
)
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import allowed_ids
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.core.model_utils import validate_model_id
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.safe_url import UnsafeUserUrlError, validate_user_base_url
|
||||
@@ -1756,14 +1757,23 @@ class ExportAgent(Resource):
|
||||
return make_response(jsonify({"success": False, "message": "id is required"}), 400)
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "export")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, user)
|
||||
# Serialized as the owner: the agent's prompt, sources, tools and
|
||||
# workflow are the owner's (secrets are never exported).
|
||||
owner_id = ra.owner_id
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, owner_id)
|
||||
try:
|
||||
export = serialize_agent(conn, agent, user)
|
||||
export = serialize_agent(conn, agent, owner_id)
|
||||
except AgentExportError as exc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(exc)}), 400
|
||||
|
||||
@@ -26,9 +26,16 @@ from docsgpt.core.json_schema_utils import (
|
||||
)
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
resolve,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import (
|
||||
can_access,
|
||||
team_access_for,
|
||||
visible_with_access,
|
||||
)
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
@@ -196,6 +203,83 @@ def _resolve_folder_id(conn, folder_id, user):
|
||||
return str(folder["id"]), None
|
||||
|
||||
|
||||
# What a caller who reached an agent only through its public share link may
|
||||
# do: chat with it and pin it. A team grant, when there is one, wins.
|
||||
LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
|
||||
|
||||
# Agent fields that are the owner's policy (guardrails and the pooled quota).
|
||||
POLICY_FIELDS = (
|
||||
"config", "token_limit", "request_limit", "limited_token_mode", "limited_request_mode",
|
||||
)
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
|
||||
"""Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``.
|
||||
|
||||
Builtin synthetic ids belong to no one. Otherwise the tool must be the
|
||||
agent owner's (it runs with the owner's credentials) or reach the caller
|
||||
with ``use_in_own``.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
tool_id: The tool id being attached.
|
||||
owner_id: The agent's owner.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
True when the attachment is allowed.
|
||||
"""
|
||||
tid = str(tool_id)
|
||||
if is_synthesized_tool_id(tid):
|
||||
return True
|
||||
if UserToolsRepository(conn).get_any(tid, owner_id) is not None:
|
||||
return True
|
||||
ra = resolve(conn, "tool", tid, caller)
|
||||
return ra is not None and ra.can("use_in_own")
|
||||
|
||||
|
||||
def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool:
|
||||
"""Whether a source/prompt may be referenced by an agent owned by ``owner_id``.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
resource_type: ``source`` or ``prompt``.
|
||||
resource_id: The referenced id.
|
||||
owner_id: The agent's owner.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
True when the agent owner owns it or the caller can ``use`` it.
|
||||
"""
|
||||
if not resource_id:
|
||||
return True
|
||||
if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id):
|
||||
return True
|
||||
return can_access(conn, resource_type, str(resource_id), caller)
|
||||
|
||||
|
||||
def _policy_changed(existing: dict, update_fields: dict) -> bool:
|
||||
"""True when ``update_fields`` changes any guardrail or quota value."""
|
||||
for key in POLICY_FIELDS:
|
||||
if key not in update_fields:
|
||||
continue
|
||||
new, old = update_fields[key], existing.get(key)
|
||||
if key == "config":
|
||||
if (new or {}) != (old or {}):
|
||||
return True
|
||||
elif key.startswith("limited_"):
|
||||
if bool(new) != bool(old):
|
||||
return True
|
||||
elif int(new or 0) != int(old or 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _reject(message: str, user: str, field: str = "-"):
|
||||
"""Log a request-validation rejection at WARN and return its 400 response.
|
||||
|
||||
@@ -228,6 +312,7 @@ def _format_agent_output(
|
||||
ownership: str = "user",
|
||||
team_access: str | None = None,
|
||||
resolve_names: bool = False,
|
||||
access: dict | None = None,
|
||||
) -> dict:
|
||||
"""Shape a PG agent row into the outward API response dict.
|
||||
|
||||
@@ -239,7 +324,16 @@ def _format_agent_output(
|
||||
``ownership`` is ``"user"`` for the caller's own agents or ``"team"`` for
|
||||
ones shared with a team they're in; ``team_access`` (``viewer``/``editor``)
|
||||
is set on team-shared agents so the UI can gate edit controls.
|
||||
|
||||
``access`` is the caller's ``{"access", "allowed_actions"}`` payload
|
||||
(owner with default switches when omitted). It is embedded verbatim and
|
||||
decides what leaves the server: the full guardrail ``config`` needs
|
||||
``view``, the (masked) ``key`` and public ``shared_token`` need
|
||||
``manage_access_details``.
|
||||
"""
|
||||
if access is None:
|
||||
access = payload_for("agent", "owner", {})
|
||||
allowed = set(access.get("allowed_actions") or [])
|
||||
source_id = agent.get("source_id")
|
||||
extra_source_ids = agent.get("extra_source_ids") or []
|
||||
source_value = str(source_id) if source_id else ""
|
||||
@@ -288,7 +382,13 @@ def _format_agent_output(
|
||||
),
|
||||
"ownership": ownership,
|
||||
"team_access": team_access,
|
||||
"access": access.get("access"),
|
||||
"allowed_actions": sorted(allowed),
|
||||
}
|
||||
# Guardrail policy is edit-page config; a chat-only caller doesn't need it
|
||||
# (and reading the banned-term list makes evading it trivial).
|
||||
if "view" not in allowed:
|
||||
out["config"] = {}
|
||||
# Resolve prompt/source NAMES by id (owner-agnostic) so a team member
|
||||
# viewing a shared agent sees the owner's prompt + source names instead of
|
||||
# a blank prompt / "External KB" (the client otherwise resolves these from
|
||||
@@ -298,9 +398,9 @@ def _format_agent_output(
|
||||
out["source_details"] = resolve_source_details(
|
||||
([source_id] if source_id else []) + list(extra_source_ids)
|
||||
)
|
||||
# Never expose the owner's share/API secrets to a team grantee — the
|
||||
# public ``shared_token`` and the (masked) agent ``key`` are owner-only.
|
||||
if ownership == "team":
|
||||
# The public ``shared_token`` and the (masked) agent ``key`` are access
|
||||
# details: only a caller who may manage them sees them.
|
||||
if "manage_access_details" not in allowed:
|
||||
out["shared_token"] = ""
|
||||
return out
|
||||
if include_key_masked:
|
||||
@@ -435,23 +535,22 @@ class GetAgent(Resource):
|
||||
return {"success": False, "message": "ID required"}, 400
|
||||
try:
|
||||
user = decoded_token["sub"]
|
||||
ownership, team_access = "user", None
|
||||
agent = None
|
||||
with db_readonly() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if not agent:
|
||||
# Team fallback: only after a grant check, fetch ownerless.
|
||||
team_access = team_access_for(conn, user, "agent", agent_id)
|
||||
if team_access:
|
||||
agent = repo.get_by_id(agent_id)
|
||||
ownership = "team"
|
||||
# Anyone who can see the agent reads it (a viewer needs it to
|
||||
# chat); what they get back is trimmed by their actions.
|
||||
ra = resolve(conn, "agent", agent_id, user)
|
||||
if ra is not None:
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return {"status": "Not found"}, 404
|
||||
is_owner = ra.access == "owner"
|
||||
data = _format_agent_output(
|
||||
agent,
|
||||
ownership=ownership,
|
||||
team_access=team_access,
|
||||
ownership="user" if is_owner else "team",
|
||||
team_access=None if is_owner else ra.access,
|
||||
resolve_names=True,
|
||||
access=ra.payload(),
|
||||
)
|
||||
return make_response(jsonify(data), 200)
|
||||
except Exception as e:
|
||||
@@ -483,10 +582,18 @@ class GetAgents(Resource):
|
||||
shared_ids = [aid for aid in team_shared if aid not in owned_ids]
|
||||
shared_agents = agents_repo.list_by_ids(shared_ids)
|
||||
|
||||
switches = settings_many(
|
||||
conn, "agent", [str(a["id"]) for a in agents + shared_agents]
|
||||
)
|
||||
|
||||
# Every agent is listed: one with no source skips retrieval and
|
||||
# answers from the model and its tools, so it is still runnable.
|
||||
list_agents = [
|
||||
_format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids)
|
||||
_format_agent_output(
|
||||
agent,
|
||||
pinned=str(agent["id"]) in pinned_ids,
|
||||
access=payload_for("agent", "owner", switches[str(agent["id"])]),
|
||||
)
|
||||
for agent in agents
|
||||
]
|
||||
list_agents += [
|
||||
@@ -494,6 +601,11 @@ class GetAgents(Resource):
|
||||
agent,
|
||||
ownership="team",
|
||||
team_access=team_shared.get(str(agent["id"])),
|
||||
access=payload_for(
|
||||
"agent",
|
||||
team_shared.get(str(agent["id"])),
|
||||
switches[str(agent["id"])],
|
||||
),
|
||||
)
|
||||
for agent in shared_agents
|
||||
]
|
||||
@@ -731,6 +843,14 @@ class CreateAgent(Resource):
|
||||
jsonify({"success": False, "message": "Prompt not accessible"}),
|
||||
403,
|
||||
)
|
||||
# Tools run with the agent owner's credentials: attach only your
|
||||
# own, or ones a team lets you use in your agents.
|
||||
for tid in data.get("tools") or []:
|
||||
if not _tool_attachable(conn, tid, user, user):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not accessible"}),
|
||||
403,
|
||||
)
|
||||
|
||||
build_data = dict(data)
|
||||
build_data["folder_id"] = pg_folder_id
|
||||
@@ -888,23 +1008,15 @@ class UpdateAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
is_team_editor = False
|
||||
existing_agent = agents_repo.get_any(agent_id, user)
|
||||
if not existing_agent:
|
||||
# Team write path: only an 'editor' grant may modify a
|
||||
# team-shared agent; a 'viewer' is read-only. Fetch the
|
||||
# ownerless row only AFTER confirming editor access.
|
||||
access = team_access_for(conn, user, "agent", agent_id)
|
||||
if access == "editor":
|
||||
existing_agent = agents_repo.get_by_id(agent_id)
|
||||
is_team_editor = True
|
||||
elif access == "viewer":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Read-only: editor access required"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "edit")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Every write lands as the owner; the row is fetched only after
|
||||
# the access check above.
|
||||
owner_id = ra.owner_id
|
||||
is_team_editor = ra.access != "owner"
|
||||
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not existing_agent:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -959,6 +1071,8 @@ class UpdateAgent(Resource):
|
||||
user,
|
||||
field,
|
||||
)
|
||||
if new_status != existing_agent.get("status") and not ra.can("publish"):
|
||||
return _denied(AccessDenied(403, "Your access to this item doesn't allow that"))
|
||||
update_fields["status"] = new_status
|
||||
elif field == "source":
|
||||
source_id = data.get("source")
|
||||
@@ -1084,10 +1198,24 @@ class UpdateAgent(Resource):
|
||||
field,
|
||||
)
|
||||
elif field == "folder_id":
|
||||
folder_input = data.get("folder_id")
|
||||
# Folders are the owner's own organisation; re-sending
|
||||
# the current folder is a no-op anyone may do.
|
||||
folder_input = data.get("folder_id") or None
|
||||
current_folder = (
|
||||
str(existing_agent["folder_id"])
|
||||
if existing_agent.get("folder_id")
|
||||
else None
|
||||
)
|
||||
if folder_input == current_folder:
|
||||
update_fields["folder_id"] = current_folder
|
||||
continue
|
||||
if not ra.can("move_folder"):
|
||||
return _denied(
|
||||
AccessDenied(403, "Only the owner can move this agent between folders")
|
||||
)
|
||||
if folder_input:
|
||||
pg_folder_id, folder_err = _resolve_folder_id(
|
||||
conn, folder_input, user,
|
||||
conn, folder_input, owner_id,
|
||||
)
|
||||
if folder_err:
|
||||
return folder_err
|
||||
@@ -1107,8 +1235,10 @@ class UpdateAgent(Resource):
|
||||
return _reject("Workflow is required", user, field)
|
||||
update_fields["workflow_id"] = None
|
||||
else:
|
||||
# The agent runs its workflow as the owner, so it
|
||||
# must be one of the owner's workflows.
|
||||
pg_workflow_id, wf_err = _resolve_workflow_for_user(
|
||||
conn, workflow_input, user,
|
||||
conn, workflow_input, owner_id,
|
||||
)
|
||||
if wf_err:
|
||||
return wf_err
|
||||
@@ -1220,7 +1350,7 @@ class UpdateAgent(Resource):
|
||||
for sid in referenced_sources:
|
||||
if str(sid) in existing_source_refs:
|
||||
continue
|
||||
if not can_access(conn, "source", sid, user):
|
||||
if not _ref_attachable(conn, "source", sid, owner_id, user):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
@@ -1228,28 +1358,24 @@ class UpdateAgent(Resource):
|
||||
if (
|
||||
new_prompt_id
|
||||
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
|
||||
and not can_access(conn, "prompt", new_prompt_id, user)
|
||||
and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user)
|
||||
):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not accessible"}), 403
|
||||
)
|
||||
# A team editor must not attach tools they can't access onto a
|
||||
# shared agent: at run time the agent-key path resolves+decrypts
|
||||
# tools as the OWNER, so an unchecked tool here would let an
|
||||
# editor invoke arbitrary owner credentials. Owners are
|
||||
# unrestricted (they own their tools). Default/builtin synthetic
|
||||
# tool ids belong to no one and are always allowed.
|
||||
if is_team_editor and "tools" in update_fields:
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
|
||||
# Tools run with the OWNER's credentials (the agent-key path
|
||||
# resolves and decrypts them as the owner), so a newly attached
|
||||
# tool must be the owner's or reach the caller with
|
||||
# ``use_in_own``. Tools already on the agent stay. Builtin
|
||||
# synthetic ids belong to no one and are always allowed.
|
||||
if "tools" in update_fields:
|
||||
existing_tools = {
|
||||
str(t) for t in (existing_agent.get("tools") or [])
|
||||
}
|
||||
for tid in update_fields["tools"] or []:
|
||||
tid_s = str(tid)
|
||||
if tid_s in existing_tools or is_synthesized_tool_id(tid_s):
|
||||
if str(tid) in existing_tools:
|
||||
continue
|
||||
if not can_access(conn, "tool", tid_s, user):
|
||||
if not _tool_attachable(conn, tid, owner_id, user):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Tool not accessible"}
|
||||
@@ -1257,19 +1383,13 @@ class UpdateAgent(Resource):
|
||||
403,
|
||||
)
|
||||
|
||||
# Per-agent quota lives on the row and is pooled across all
|
||||
# members; only the owner may resize that shared pool, so a
|
||||
# team editor's quota changes are dropped.
|
||||
if is_team_editor:
|
||||
for _q in (
|
||||
"token_limit", "request_limit",
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
# Guardrails are the owner's policy for their agent.
|
||||
# An editor who could clear them would silently strip
|
||||
# protection from everyone else using it.
|
||||
"config",
|
||||
):
|
||||
update_fields.pop(_q, None)
|
||||
# Guardrails and the pooled quota are policy: an unchanged
|
||||
# value re-sent by a full-form save is fine, a change needs
|
||||
# ``edit_policy``.
|
||||
if not ra.can("edit_policy") and _policy_changed(existing_agent, update_fields):
|
||||
return _denied(
|
||||
AccessDenied(403, "Your access doesn't allow changing guardrails or limits")
|
||||
)
|
||||
|
||||
# Apply update. Owner writes use the dual-key guard; team-editor
|
||||
# writes go by-id (already authorized) with an optimistic-lock
|
||||
@@ -1328,7 +1448,9 @@ class UpdateAgent(Resource):
|
||||
"id": pg_agent_id,
|
||||
"message": "Agent updated successfully",
|
||||
}
|
||||
if newly_generated_key:
|
||||
# A freshly minted key is an access detail: returned only to a caller
|
||||
# who may manage it (the key is still stored either way).
|
||||
if newly_generated_key and ra.can("manage_access_details"):
|
||||
response_data["key"] = (
|
||||
newly_generated_key
|
||||
if may_see_agent_keys(request)
|
||||
@@ -1358,10 +1480,13 @@ class RegenerateAgentKey(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
# Owner-only: rotating a credential is destructive to live
|
||||
# integrations, so this is intentionally stricter than
|
||||
# update_agent (which also allows team editors).
|
||||
existing_agent = agents_repo.get_any(agent_id, user)
|
||||
# Rotating the key is an access detail: the owner, or an editor
|
||||
# while ``editors_can_manage_access_details`` is on.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not existing_agent:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1389,7 +1514,7 @@ class RegenerateAgentKey(Resource):
|
||||
)
|
||||
|
||||
new_key = str(uuid.uuid4())
|
||||
updated = agents_repo.update(pg_agent_id, user, {"key": new_key})
|
||||
updated = agents_repo.update(pg_agent_id, ra.owner_id, {"key": new_key})
|
||||
if not updated:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1460,7 +1585,12 @@ class DeleteAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
agent = agents_repo.get_any(agent_id, user)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "delete")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
owner_id = ra.owner_id
|
||||
agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -1476,14 +1606,20 @@ class DeleteAgent(Resource):
|
||||
# that user's graph.
|
||||
if agent.get("agent_type") == "workflow" and workflow_id:
|
||||
try:
|
||||
WorkflowsRepository(conn).delete(str(workflow_id), user)
|
||||
WorkflowsRepository(conn).delete(str(workflow_id), owner_id)
|
||||
except Exception as wf_err:
|
||||
current_app.logger.warning(
|
||||
f"Workflow cleanup failed for agent {pg_agent_id}: {wf_err}"
|
||||
)
|
||||
agents_repo.delete(pg_agent_id, user)
|
||||
# Strip pinned/shared entries for this agent from the owner's prefs.
|
||||
UsersRepository(conn).remove_agent_from_all(user, pg_agent_id)
|
||||
# Team grants go with the row (AFTER DELETE trigger, 0021);
|
||||
# the switches table has no FK, so drop it explicitly.
|
||||
agents_repo.delete(pg_agent_id, owner_id)
|
||||
delete_settings(conn, "agent", pg_agent_id)
|
||||
# Strip pinned/shared entries for this agent from the owner's
|
||||
# (and the deleting editor's) prefs.
|
||||
users_repo = UsersRepository(conn)
|
||||
for uid in {owner_id, user}:
|
||||
users_repo.remove_agent_from_all(uid, pg_agent_id)
|
||||
record_event(
|
||||
conn,
|
||||
"agent.deleted",
|
||||
@@ -1579,10 +1715,19 @@ class PinnedAgents(Resource):
|
||||
for agent in pinned_agents
|
||||
if _user_may_pin(conn, agent, user_id, shared_with_me)
|
||||
]
|
||||
# A pin reached only through a share link (or a template)
|
||||
# has no grant: chat + pin, nothing more.
|
||||
access_by_id = {}
|
||||
for agent in pinned_agents:
|
||||
ra = resolve(conn, "agent", str(agent["id"]), user_id)
|
||||
access_by_id[str(agent["id"])] = (
|
||||
ra.payload() if ra is not None else dict(LINK_SHARED_ACCESS)
|
||||
)
|
||||
|
||||
list_pinned_agents = []
|
||||
for agent in pinned_agents:
|
||||
source_id = agent.get("source_id")
|
||||
access = access_by_id[str(agent["id"])]
|
||||
list_pinned_agents.append(
|
||||
{
|
||||
"id": str(agent["id"]),
|
||||
@@ -1608,10 +1753,12 @@ class PinnedAgents(Resource):
|
||||
"last_used_at": agent.get("last_used_at", ""),
|
||||
"key": (
|
||||
f"{agent['key'][:4]}...{agent['key'][-4:]}"
|
||||
if agent.get("key") and agent.get("user_id") == user_id
|
||||
if agent.get("key")
|
||||
and "manage_access_details" in access["allowed_actions"]
|
||||
else ""
|
||||
),
|
||||
"pinned": True,
|
||||
**access,
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import text as _sql_text
|
||||
from docsgpt.api import api
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.api.user.base import resolve_tool_details
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require, resolve
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
@@ -21,12 +22,38 @@ agents_sharing_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
# A caller who reached an agent only through its public link may chat with it
|
||||
# and pin it; a team grant, when there is one, gives more.
|
||||
LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
|
||||
|
||||
|
||||
def _link_access(conn, agent_id: str, user_id) -> dict:
|
||||
"""The ``access`` payload for an agent the caller reached by share link.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
agent_id: The agent's id.
|
||||
user_id: The caller, or None when anonymous.
|
||||
|
||||
Returns:
|
||||
The caller's own access (owner or a team grant) when they have one,
|
||||
else viewer with ``pin`` and ``use``.
|
||||
"""
|
||||
if user_id:
|
||||
ra = resolve(conn, "agent", agent_id, user_id)
|
||||
if ra is not None:
|
||||
return ra.payload()
|
||||
return dict(LINK_SHARED_ACCESS)
|
||||
|
||||
|
||||
def _serialize_agent_basic(agent: dict) -> dict:
|
||||
"""Shape a PG agent row into the API response dict."""
|
||||
"""Shape a PG agent row into the API response dict.
|
||||
|
||||
The owner's user id is deliberately not included: a share link is public.
|
||||
"""
|
||||
source_id = agent.get("source_id")
|
||||
return {
|
||||
"id": str(agent["id"]),
|
||||
"user": agent.get("user_id", ""),
|
||||
"name": agent.get("name", ""),
|
||||
"image": (
|
||||
generate_image_url(
|
||||
@@ -91,8 +118,8 @@ class SharedAgent(Resource):
|
||||
enriched_tools.append(detail.get("name", ""))
|
||||
data["tools"] = enriched_tools
|
||||
decoded_token = getattr(request, "decoded_token", None)
|
||||
if decoded_token:
|
||||
user_id = decoded_token.get("sub")
|
||||
user_id = decoded_token.get("sub") if decoded_token else None
|
||||
if user_id:
|
||||
owner_id = shared_agent.get("user_id")
|
||||
|
||||
if user_id != owner_id:
|
||||
@@ -100,6 +127,8 @@ class SharedAgent(Resource):
|
||||
users_repo = UsersRepository(conn)
|
||||
users_repo.upsert(user_id)
|
||||
users_repo.add_shared(user_id, agent_id)
|
||||
with db_readonly() as conn:
|
||||
data.update(_link_access(conn, agent_id, user_id))
|
||||
return make_response(jsonify(data), 200)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving shared agent: {err}")
|
||||
@@ -152,6 +181,10 @@ class SharedAgents(Resource):
|
||||
if isinstance(user_doc.get("agent_preferences"), dict)
|
||||
else []
|
||||
)
|
||||
access_by_id = {
|
||||
str(agent["id"]): _link_access(conn, str(agent["id"]), user_id)
|
||||
for agent in shared_agents
|
||||
}
|
||||
|
||||
list_shared_agents = []
|
||||
for agent in shared_agents:
|
||||
@@ -185,6 +218,7 @@ class SharedAgents(Resource):
|
||||
"shared": bool(agent.get("shared", False)),
|
||||
"shared_token": agent.get("shared_token", "") or "",
|
||||
"shared_metadata": agent.get("shared_metadata", {}) or {},
|
||||
**access_by_id[agent_id_str],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -244,7 +278,15 @@ class ShareAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
# The public link is an access detail; it is written as the owner.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
owner_id = ra.owner_id
|
||||
agent = repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -258,7 +300,7 @@ class ShareAgent(Resource):
|
||||
}
|
||||
shared_token = secrets.token_urlsafe(32)
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), owner_id,
|
||||
{
|
||||
"shared": True,
|
||||
"shared_token": shared_token,
|
||||
@@ -267,7 +309,7 @@ class ShareAgent(Resource):
|
||||
)
|
||||
else:
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), owner_id,
|
||||
{
|
||||
"shared": False,
|
||||
"shared_token": None,
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.base import require_agent
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.api.user.tasks import process_agent_webhook
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
@@ -71,7 +72,14 @@ class AgentWebhook(Resource):
|
||||
)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
agent = AgentsRepository(conn).get_any(agent_id, user)
|
||||
# The webhook URL is an access detail; it is minted as the owner.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -81,7 +89,7 @@ class AgentWebhook(Resource):
|
||||
webhook_token = secrets.token_urlsafe(32)
|
||||
with db_session() as conn:
|
||||
AgentsRepository(conn).update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), ra.owner_id,
|
||||
{"incoming_webhook_token": webhook_token},
|
||||
)
|
||||
base_url = settings.API_URL.rstrip("/")
|
||||
|
||||
@@ -8,6 +8,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import Connection, text as _sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, resolve
|
||||
from docsgpt.api.user.base import (
|
||||
generate_date_range,
|
||||
generate_hourly_range,
|
||||
@@ -74,27 +75,38 @@ def _intervals_for_filter(filter_option, start_date, end_date):
|
||||
|
||||
|
||||
def _resolve_agent(conn, api_key_id, user_id):
|
||||
"""Owner-scoped agent lookup for analytics filters.
|
||||
"""Access-checked agent lookup for analytics filters.
|
||||
|
||||
Returns ``(agent, api_key, agent_pg_id)``. ``agent`` is ``None`` when
|
||||
the id doesn't resolve to one of the caller's agents — callers must
|
||||
the id doesn't resolve to an agent the caller can see — callers must
|
||||
short-circuit with an empty result, not fall back to sentinel filter
|
||||
values. ``api_key`` is ``None`` (never ``""``) for key-less agents:
|
||||
draft agents store ``key = ''``, and an ``''`` filter would match the
|
||||
``''`` that writers like ``stack_logs`` stamp on every key-less
|
||||
request — leaking rows across users. NULL matches nothing. Accepts
|
||||
UUID or legacy Mongo ObjectId ids.
|
||||
values. A visible agent needs ``view_logs`` (owner and editors; viewers
|
||||
when the owner turns on ``viewers_can_see_logs``), and the caller then
|
||||
sees exactly the owner's view of it. ``api_key`` is ``None`` (never
|
||||
``""``) for key-less agents: draft agents store ``key = ''``, and an
|
||||
``''`` filter would match the ``''`` that writers like ``stack_logs``
|
||||
stamp on every key-less request — leaking rows across users. NULL
|
||||
matches nothing. Accepts UUID or legacy Mongo ObjectId ids.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 403 when the agent is visible but ``view_logs`` isn't allowed.
|
||||
"""
|
||||
agent = (
|
||||
AgentsRepository(conn).get_any(api_key_id, user_id)
|
||||
if api_key_id
|
||||
else None
|
||||
)
|
||||
ra = resolve(conn, "agent", api_key_id, user_id) if api_key_id else None
|
||||
if ra is None:
|
||||
return None, None, None
|
||||
if not ra.can("view_logs"):
|
||||
raise AccessDenied(403, "Your access to this agent doesn't include its logs")
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
api_key = (agent or {}).get("key") or None
|
||||
agent_pg_id = str(agent["id"]) if agent else None
|
||||
return agent, api_key, agent_pg_id
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _trace_branch(name: str, sources_sql: str, scope: str) -> dict:
|
||||
"""A ``get_user_logs`` branch listing stored traces of the given sources."""
|
||||
return {
|
||||
@@ -238,6 +250,8 @@ class GetTraces(Resource):
|
||||
traces = RequestTracesRepository(conn).list_by_ref(
|
||||
field, value, user_id=user, agent_id=agent_pg_id
|
||||
)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error getting traces: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -342,6 +356,8 @@ class GetMessageAnalytics(Resource):
|
||||
daily_messages = {interval: 0 for interval in intervals}
|
||||
for row in rows:
|
||||
daily_messages[row._mapping["bucket"]] = int(row._mapping["count"])
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting message analytics: {err}", exc_info=True
|
||||
@@ -466,6 +482,8 @@ class GetTokenAnalytics(Resource):
|
||||
if key not in series:
|
||||
series[key] = {interval: 0 for interval in intervals}
|
||||
series[key][bucket] = series[key].get(bucket, 0) + total
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting token analytics: {err}", exc_info=True
|
||||
@@ -592,6 +610,8 @@ class GetFeedbackAnalytics(Resource):
|
||||
"positive": int(row._mapping["positive"] or 0),
|
||||
"negative": int(row._mapping["negative"] or 0),
|
||||
}
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting feedback analytics: {err}", exc_info=True
|
||||
@@ -710,6 +730,8 @@ class GetToolAnalytics(Resource):
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting tool analytics: {err}", exc_info=True
|
||||
@@ -825,6 +847,8 @@ class GetScheduleAnalytics(Resource):
|
||||
"failed": int(row._mapping["failed"] or 0),
|
||||
"skipped": int(row._mapping["skipped"] or 0),
|
||||
}
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting schedule analytics: {err}", exc_info=True
|
||||
@@ -927,7 +951,8 @@ class GetUserLogs(Resource):
|
||||
200,
|
||||
)
|
||||
params: dict = {
|
||||
"user_id": user,
|
||||
# Agent-scoped logs are the owner's view of the agent.
|
||||
"user_id": agent["user_id"] if agent else user,
|
||||
"limit": page_size + 1,
|
||||
"offset": (page - 1) * page_size,
|
||||
}
|
||||
@@ -1313,6 +1338,8 @@ class GetUserLogs(Resource):
|
||||
"Could not attach trace summaries to the logs page",
|
||||
exc_info=True,
|
||||
)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting user logs: {err}", exc_info=True
|
||||
|
||||
@@ -6,7 +6,14 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.team_sharing import team_access_for, visible_with_access
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.prompts.composer import compose_preset, is_composed_preset
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -17,6 +24,16 @@ prompts_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (403 or 404)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _iso(value):
|
||||
"""ISO-8601 string for a timestamp (``expected_updated_at`` round-trips it)."""
|
||||
return value.isoformat() if hasattr(value, "isoformat") else value
|
||||
|
||||
|
||||
@prompts_ns.route("/create_prompt")
|
||||
class CreatePrompt(Resource):
|
||||
create_prompt_model = api.model(
|
||||
@@ -67,26 +84,35 @@ class GetPrompts(Resource):
|
||||
team_shared = visible_with_access(conn, user, "prompt")
|
||||
shared_ids = [pid for pid in team_shared if pid not in owned_ids]
|
||||
shared_prompts = repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(
|
||||
conn, "prompt", [*owned_ids, *(str(p["id"]) for p in shared_prompts)]
|
||||
)
|
||||
list_prompts = [
|
||||
{"id": "default", "name": "default", "type": "public"},
|
||||
{"id": "creative", "name": "creative", "type": "public"},
|
||||
{"id": "strict", "name": "strict", "type": "public"},
|
||||
]
|
||||
for prompt in prompts:
|
||||
pid = str(prompt["id"])
|
||||
list_prompts.append(
|
||||
{
|
||||
"id": str(prompt["id"]),
|
||||
"id": pid,
|
||||
"name": prompt["name"],
|
||||
"type": "private",
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**payload_for("prompt", "owner", switches.get(pid)),
|
||||
}
|
||||
)
|
||||
for prompt in shared_prompts:
|
||||
pid = str(prompt["id"])
|
||||
list_prompts.append(
|
||||
{
|
||||
"id": str(prompt["id"]),
|
||||
"id": pid,
|
||||
"name": prompt["name"],
|
||||
"type": "team",
|
||||
"team_access": team_shared.get(str(prompt["id"])),
|
||||
"team_access": team_shared.get(pid),
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**payload_for("prompt", team_shared.get(pid), switches.get(pid)),
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
@@ -115,19 +141,28 @@ class GetSinglePrompt(Resource):
|
||||
jsonify({"content": compose_preset(prompt_id)}), 200
|
||||
)
|
||||
with db_readonly() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(prompt_id, user)
|
||||
if not prompt and team_access_for(conn, user, "prompt", prompt_id):
|
||||
# Team fallback: ownerless fetch only after a grant check.
|
||||
prompt = repo.get_for_rendering(prompt_id)
|
||||
ra = require(conn, "prompt", prompt_id, user, "use")
|
||||
prompt = PromptsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
if not prompt:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}), 404
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(jsonify({"content": prompt["content"]}), 200)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"content": prompt["content"],
|
||||
"name": prompt.get("name"),
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**ra.payload(),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@prompts_ns.route("/delete_prompt")
|
||||
@@ -151,14 +186,12 @@ class DeletePrompt(Resource):
|
||||
return missing_fields
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(data["id"], user)
|
||||
if not prompt:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}),
|
||||
404,
|
||||
)
|
||||
repo.delete(str(prompt["id"]), user)
|
||||
ra = require(conn, "prompt", data["id"], user, "delete")
|
||||
# Grants go with the row (delete trigger); switches have no FK.
|
||||
PromptsRepository(conn).delete(ra.resource_id, ra.owner_id)
|
||||
delete_settings(conn, "prompt", ra.resource_id)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -192,43 +225,26 @@ class UpdatePrompt(Resource):
|
||||
return missing_fields
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(data["id"], user)
|
||||
if prompt:
|
||||
repo.update(str(prompt["id"]), user, data["name"], data["content"])
|
||||
else:
|
||||
# Team editor write path (viewer is read-only).
|
||||
access = team_access_for(conn, user, "prompt", data["id"])
|
||||
if access == "editor":
|
||||
result = repo.update_by_id(
|
||||
data["id"],
|
||||
data["name"],
|
||||
data["content"],
|
||||
expected_updated_at=data.get("expected_updated_at"),
|
||||
)
|
||||
if result is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Prompt was modified by someone else",
|
||||
"code": "stale_write",
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
elif access == "viewer":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Read-only: editor access required"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}),
|
||||
404,
|
||||
)
|
||||
ra = require(conn, "prompt", data["id"], user, "edit")
|
||||
result = PromptsRepository(conn).update_by_id(
|
||||
ra.resource_id,
|
||||
data["name"],
|
||||
data["content"],
|
||||
expected_updated_at=data.get("expected_updated_at"),
|
||||
)
|
||||
if result is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Prompt was modified by someone else",
|
||||
"code": "stale_write",
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error updating prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""The one access check for team-shared resources: roles, actions and switches.
|
||||
|
||||
Every shareable resource (``agent``, ``source``, ``tool``, ``prompt``) has an
|
||||
owner and may be shared to teams as ``viewer`` or ``editor``. What each role
|
||||
may do is a fixed table of *actions* per resource type (``ACTIONS``). The
|
||||
owner can adjust a few of those rows on one resource with *switches*
|
||||
(``SWITCHES``), stored in ``resource_share_settings``. A switch only ever moves
|
||||
one action between two roles; it never touches owner-only actions such as
|
||||
``manage_settings``.
|
||||
|
||||
Routes ask one question, ``require(conn, type, id, user, action)``, and get
|
||||
back a :class:`ResourceAccess` (whose ``owner_id`` is the id to write as) or
|
||||
an :class:`AccessDenied` carrying 404 (not visible) or 403 (visible, but the
|
||||
role may not do this). List and get responses embed ``ResourceAccess.payload()``
|
||||
(``access`` + ``allowed_actions``) so the frontend never re-derives the rules.
|
||||
|
||||
Access is resolved live on every call (grants JOIN ``team_members``), so a
|
||||
revoked grant or membership denies on the next request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
RESOURCE_TYPES = ("agent", "source", "tool", "prompt")
|
||||
|
||||
# Weakest role that may perform each action by default. ``owner`` rows are
|
||||
# owner-only unless a switch below moves them.
|
||||
ACTIONS: dict[str, dict[str, str]] = {
|
||||
"agent": {
|
||||
"use": "viewer", # chat with it
|
||||
"pin": "viewer",
|
||||
"view": "editor", # open the edit page and read its full config
|
||||
"edit": "editor",
|
||||
"publish": "editor",
|
||||
"edit_policy": "editor", # guardrails and quotas
|
||||
"view_logs": "editor",
|
||||
"manage_schedules": "editor",
|
||||
"export": "editor",
|
||||
"manage_access_details": "editor", # API key, webhook, public link
|
||||
"move_folder": "owner",
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"source": {
|
||||
"use": "viewer", # browse files, chunks, graph, wiki; test retrieval; attach to agents
|
||||
"view_config": "editor",
|
||||
"edit": "editor", # chunks, files, wiki, config, sync, reingest, GraphRAG, convert
|
||||
"reconnect": "owner", # change the connector account
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"tool": {
|
||||
"use": "viewer", # see it and run it inside the owner's shared agents
|
||||
"use_in_own": "viewer", # add it to my own agents and chats
|
||||
"edit": "editor", # name, action descriptions, parameters, approval
|
||||
"edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only)
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"prompt": {
|
||||
"use": "viewer", # read it and use it in my own agents
|
||||
"duplicate": "editor",
|
||||
"edit": "editor",
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Switch:
|
||||
"""One owner switch: moves ``action`` to ``role_on`` or ``role_off``."""
|
||||
|
||||
key: str
|
||||
default: bool
|
||||
action: str
|
||||
role_on: str
|
||||
role_off: str
|
||||
|
||||
|
||||
# Order is the order the share dialog lists them in.
|
||||
SWITCHES: dict[str, tuple[Switch, ...]] = {
|
||||
"agent": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("editors_can_delete", False, "delete", "editor", "owner"),
|
||||
Switch("editors_can_manage_access_details", True, "manage_access_details", "editor", "owner"),
|
||||
Switch("viewers_can_see_logs", False, "view_logs", "viewer", "editor"),
|
||||
),
|
||||
"source": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("editors_can_delete", False, "delete", "editor", "owner"),
|
||||
Switch("viewers_can_see_config", True, "view_config", "viewer", "editor"),
|
||||
),
|
||||
"tool": (
|
||||
Switch("editors_can_change_credentials", True, "edit_credentials", "editor", "owner"),
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("viewers_can_use_in_agents", True, "use_in_own", "viewer", "editor"),
|
||||
),
|
||||
"prompt": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("viewers_can_duplicate", True, "duplicate", "viewer", "editor"),
|
||||
),
|
||||
}
|
||||
|
||||
_RANK = {"viewer": 1, "editor": 2, "owner": 3}
|
||||
|
||||
_REPO_FOR_TYPE = {
|
||||
"agent": AgentsRepository,
|
||||
"source": SourcesRepository,
|
||||
"prompt": PromptsRepository,
|
||||
"tool": UserToolsRepository,
|
||||
}
|
||||
|
||||
|
||||
class AccessDenied(Exception):
|
||||
"""Raised by :func:`require`; ``status`` is 404 (not visible) or 403."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def default_settings(resource_type: str) -> dict[str, bool]:
|
||||
"""Every switch of ``resource_type`` at its default."""
|
||||
return {s.key: s.default for s in SWITCHES.get(resource_type, ())}
|
||||
|
||||
|
||||
def _merge(resource_type: str, stored: Optional[dict]) -> dict[str, bool]:
|
||||
merged = default_settings(resource_type)
|
||||
for key, value in (stored or {}).items():
|
||||
if key in merged and isinstance(value, bool):
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def role_table(resource_type: str, settings: Optional[dict]) -> dict[str, str]:
|
||||
"""``action -> weakest role`` for one resource, switches applied."""
|
||||
table = dict(ACTIONS[resource_type])
|
||||
merged = _merge(resource_type, settings)
|
||||
for switch in SWITCHES.get(resource_type, ()):
|
||||
table[switch.action] = switch.role_on if merged[switch.key] else switch.role_off
|
||||
return table
|
||||
|
||||
|
||||
def allowed_actions(
|
||||
resource_type: str, access: Optional[str], settings: Optional[dict]
|
||||
) -> set[str]:
|
||||
"""The actions ``access`` may perform on a resource with these switches."""
|
||||
if access not in _RANK or resource_type not in ACTIONS:
|
||||
return set()
|
||||
rank = _RANK[access]
|
||||
return {action for action, role in role_table(resource_type, settings).items() if rank >= _RANK[role]}
|
||||
|
||||
|
||||
def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
|
||||
"""The switches as ``[{key, value, default}]`` in display order."""
|
||||
merged = _merge(resource_type, settings)
|
||||
return [
|
||||
{"key": s.key, "value": merged[s.key], "default": s.default}
|
||||
for s in SWITCHES.get(resource_type, ())
|
||||
]
|
||||
|
||||
|
||||
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
|
||||
"""The resource's switches, defaults filled in."""
|
||||
return settings_many(conn, resource_type, [resource_id])[resource_id]
|
||||
|
||||
|
||||
def settings_many(
|
||||
conn: Connection, resource_type: str, resource_ids: Iterable[str]
|
||||
) -> dict[str, dict[str, bool]]:
|
||||
"""``resource_id -> switches`` for many resources in one query."""
|
||||
ids = [str(r) for r in resource_ids]
|
||||
out = {rid: default_settings(resource_type) for rid in ids}
|
||||
uuids = [rid for rid in ids if looks_like_uuid(rid)]
|
||||
if not uuids:
|
||||
return out
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT resource_id, settings FROM resource_share_settings
|
||||
WHERE resource_type = :t AND resource_id = ANY(CAST(:ids AS uuid[]))
|
||||
"""
|
||||
),
|
||||
{"t": resource_type, "ids": uuids},
|
||||
).fetchall()
|
||||
for rid, stored in rows:
|
||||
out[str(rid)] = _merge(resource_type, stored)
|
||||
return out
|
||||
|
||||
|
||||
def set_settings(
|
||||
conn: Connection, resource_type: str, resource_id: str, changes: dict, updated_by: str
|
||||
) -> dict[str, bool]:
|
||||
"""Merge ``changes`` into the resource's switches and return the result.
|
||||
|
||||
Raises:
|
||||
ValueError: an unknown key or a non-boolean value.
|
||||
"""
|
||||
known = default_settings(resource_type)
|
||||
for key, value in changes.items():
|
||||
if key not in known:
|
||||
raise ValueError(f"Unknown setting: {key}")
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"Setting {key} must be true or false")
|
||||
merged = {**settings_for(conn, resource_type, resource_id), **changes}
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO resource_share_settings (resource_type, resource_id, settings, updated_by)
|
||||
VALUES (:t, CAST(:id AS uuid), CAST(:s AS jsonb), :by)
|
||||
ON CONFLICT (resource_type, resource_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
"""
|
||||
),
|
||||
{"t": resource_type, "id": resource_id, "s": json.dumps(merged), "by": updated_by},
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def delete_settings(conn: Connection, resource_type: str, resource_id: str) -> None:
|
||||
"""Drop a deleted resource's switches (the table has no FK to cascade)."""
|
||||
if looks_like_uuid(resource_id):
|
||||
conn.execute(
|
||||
text("DELETE FROM resource_share_settings WHERE resource_type = :t AND resource_id = CAST(:id AS uuid)"),
|
||||
{"t": resource_type, "id": resource_id},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceAccess:
|
||||
"""What one user may do on one resource."""
|
||||
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
access: str # owner | editor | viewer
|
||||
owner_id: str # the id to read and write the resource as
|
||||
settings: dict = field(default_factory=dict)
|
||||
actions: frozenset = frozenset()
|
||||
|
||||
def can(self, action: str) -> bool:
|
||||
return action in self.actions
|
||||
|
||||
def payload(self) -> dict:
|
||||
"""The fields every API response embeds for this resource."""
|
||||
return {"access": self.access, "allowed_actions": sorted(self.actions)}
|
||||
|
||||
|
||||
def build(resource_type: str, resource_id: str, access: str, owner_id: str, settings: dict) -> ResourceAccess:
|
||||
"""A :class:`ResourceAccess` from already-known parts (list endpoints)."""
|
||||
merged = _merge(resource_type, settings)
|
||||
return ResourceAccess(
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id),
|
||||
access=access,
|
||||
owner_id=owner_id,
|
||||
settings=merged,
|
||||
actions=frozenset(allowed_actions(resource_type, access, merged)),
|
||||
)
|
||||
|
||||
|
||||
def payload_for(resource_type: str, access: Optional[str], settings: Optional[dict]) -> dict:
|
||||
"""``access`` + ``allowed_actions`` without an owner lookup (list endpoints)."""
|
||||
return {
|
||||
"access": access,
|
||||
"allowed_actions": sorted(allowed_actions(resource_type, access, settings)),
|
||||
}
|
||||
|
||||
|
||||
def resolve(
|
||||
conn: Connection, resource_type: str, resource_id: str, user_id: str
|
||||
) -> Optional[ResourceAccess]:
|
||||
"""The caller's access to a resource, or None when they can't see it."""
|
||||
repo_cls = _REPO_FOR_TYPE.get(resource_type)
|
||||
if repo_cls is None or not resource_id or not user_id:
|
||||
return None
|
||||
owned = repo_cls(conn).get_any(str(resource_id), user_id)
|
||||
if owned is not None:
|
||||
rid = str(owned.get("id") or resource_id)
|
||||
return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
|
||||
# Only canonical UUIDs can carry a grant; casting anything else would
|
||||
# poison the transaction.
|
||||
if not looks_like_uuid(str(resource_id)):
|
||||
return None
|
||||
level = TeamScopeRepository(conn).effective_access(user_id, resource_type, str(resource_id))
|
||||
if level is None:
|
||||
return None
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_resource(resource_type, str(resource_id))
|
||||
if not grants:
|
||||
return None
|
||||
# Every grant row carries the same denormalised owner id.
|
||||
owner_id = grants[0].get("owner_id")
|
||||
return build(resource_type, str(resource_id), level, owner_id, settings_for(conn, resource_type, str(resource_id)))
|
||||
|
||||
|
||||
def require(
|
||||
conn: Connection, resource_type: str, resource_id: str, user_id: str, action: str
|
||||
) -> ResourceAccess:
|
||||
"""Resolve and check one action.
|
||||
|
||||
Raises:
|
||||
KeyError: ``action`` is not an action of ``resource_type`` (a bug).
|
||||
AccessDenied: 404 when the resource isn't visible, 403 when the
|
||||
caller's role may not perform ``action``.
|
||||
"""
|
||||
if action not in ACTIONS.get(resource_type, {}):
|
||||
raise KeyError(f"{resource_type} has no action {action!r}")
|
||||
ra = resolve(conn, resource_type, resource_id, user_id)
|
||||
if ra is None:
|
||||
raise AccessDenied(404, f"{resource_type.capitalize()} not found")
|
||||
if not ra.can(action):
|
||||
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
||||
return ra
|
||||
@@ -206,6 +206,33 @@ def _append_one_time_turn(
|
||||
return message
|
||||
|
||||
|
||||
def _scheduler_still_allowed(schedule: Dict[str, Any], agent_config: Dict[str, Any]) -> bool:
|
||||
"""Whether the schedule's user may still run this agent.
|
||||
|
||||
The run always executes as the agent's owner. A schedule stored under
|
||||
someone else (set from chat on a shared agent) needs that user to still
|
||||
see the agent — a live team grant, or a public link that is still on —
|
||||
so revoking a grant stops their schedules on the next tick.
|
||||
|
||||
Args:
|
||||
schedule: The schedule row.
|
||||
agent_config: The agent row (or the agentless ephemeral config).
|
||||
|
||||
Returns:
|
||||
True when the run may proceed.
|
||||
"""
|
||||
user_id = schedule.get("user_id")
|
||||
agent_id = agent_config.get("id")
|
||||
if not agent_id or not user_id or agent_config.get("user_id") == user_id:
|
||||
return True
|
||||
if agent_config.get("shared"):
|
||||
return True
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
with get_engine().connect() as conn:
|
||||
return resolve(conn, "agent", str(agent_id), user_id) is not None
|
||||
|
||||
|
||||
def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Execute one scheduled run by id; returns a result dict for tracing."""
|
||||
if not settings.POSTGRES_URI:
|
||||
@@ -256,6 +283,22 @@ def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Di
|
||||
error="agent missing")
|
||||
return {"status": "failed", "reason": "agent missing"}
|
||||
|
||||
if not _scheduler_still_allowed(schedule, agent_config):
|
||||
with engine.begin() as conn:
|
||||
updated = ScheduleRunsRepository(conn).update(
|
||||
run_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(timezone.utc),
|
||||
"error_type": "internal",
|
||||
"error": "agent access revoked",
|
||||
},
|
||||
)
|
||||
SchedulesRepository(conn).bump_failure_count(str(schedule["id"]))
|
||||
_publish_run_event("schedule.run.failed", updated or run, schedule,
|
||||
error="agent access revoked")
|
||||
return {"status": "failed", "reason": "agent access revoked"}
|
||||
|
||||
with engine.begin() as conn:
|
||||
if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id):
|
||||
return {"status": "skipped", "reason": "lost race to mark_running"}
|
||||
|
||||
@@ -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"]
|
||||
):
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Role checks shared by the source routes (sources, chunks, upload, search).
|
||||
|
||||
Thin wrappers over :mod:`docsgpt.api.user.resource_access` so every source
|
||||
endpoint resolves the row the same way and answers denials with the same
|
||||
JSON shape: 404 when the caller can't see the source, 403 when they can but
|
||||
their role may not perform the action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from flask import jsonify, make_response
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
|
||||
def load_source(
|
||||
conn: Connection, source_id: Optional[str], user: str, action: str
|
||||
) -> tuple[dict, ResourceAccess]:
|
||||
"""Check ``action`` on a source and return its row with the caller's access.
|
||||
|
||||
The row is fetched by the canonical id only after the check passes, so a
|
||||
team member gets the owner's row without ownership scoping.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
source_id: Source id from the request (UUID or legacy id).
|
||||
user: The caller's ``sub``.
|
||||
action: A ``source`` action from ``resource_access.ACTIONS``.
|
||||
|
||||
Returns:
|
||||
tuple: ``(source_row, ResourceAccess)``; write as ``ra.owner_id``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't do it.
|
||||
"""
|
||||
if not source_id:
|
||||
raise AccessDenied(404, "Source not found")
|
||||
ra = require(conn, "source", str(source_id), user, action)
|
||||
doc = SourcesRepository(conn).get_by_id(ra.resource_id)
|
||||
if doc is None:
|
||||
raise AccessDenied(404, "Source not found")
|
||||
return doc, ra
|
||||
|
||||
|
||||
def denied_response(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": err.message}), err.status
|
||||
)
|
||||
@@ -7,8 +7,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.base import get_vector_store
|
||||
from docsgpt.api.user.team_sharing import can_access, effective_write_owner
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
from docsgpt.utils import check_required_fields, num_tokens_from_string
|
||||
from docsgpt.vectorstore.base import InvalidChunkMetadataError
|
||||
@@ -18,38 +18,27 @@ sources_chunks_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_source(doc_id: str, user: str):
|
||||
"""Resolve a source (UUID or legacy ObjectId) the caller may READ.
|
||||
def _resolve_source(doc_id: str, user: str, action: str = "use") -> dict:
|
||||
"""Resolve a source (UUID or legacy ObjectId) the caller may ``action`` on.
|
||||
|
||||
Read access = owner or any team grant (viewer/editor). Returns the row
|
||||
dict (with PG UUID in ``id``) or ``None`` if missing or not visible.
|
||||
``use`` (browse chunks) is open to every role; ``edit`` (add / delete /
|
||||
update chunks) needs owner or team editor. The vector partition is keyed
|
||||
by source id, so a team editor's write needs no owner id.
|
||||
|
||||
Args:
|
||||
doc_id: Source id from the request.
|
||||
user: The caller's ``sub``.
|
||||
action: ``use`` for reads, ``edit`` for chunk writes.
|
||||
|
||||
Returns:
|
||||
dict: The source row (PG UUID in ``id``).
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't do it.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
if not can_access(conn, "source", doc_id, user):
|
||||
return None
|
||||
return SourcesRepository(conn).get_by_id(doc_id)
|
||||
|
||||
|
||||
def _resolve_source_for_write(doc_id: str, user: str):
|
||||
"""Resolve a source the caller may WRITE chunks on.
|
||||
|
||||
Returns the row dict when ``user`` owns the source, or when they hold a
|
||||
team ``editor`` grant (adding/removing/editing documents is editor-allowed
|
||||
— the vector partition is keyed by source_id, owner-agnostic). Returns
|
||||
``None`` for viewer-only / no access. Source deletion stays owner-only and
|
||||
is handled elsewhere.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
owner = effective_write_owner(conn, "source", doc_id, user)
|
||||
if not owner:
|
||||
return None
|
||||
return SourcesRepository(conn).get_any(doc_id, owner)
|
||||
doc, _ra = load_source(conn, doc_id, user, action)
|
||||
return doc
|
||||
|
||||
|
||||
def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None:
|
||||
@@ -193,13 +182,11 @@ class GetChunks(Resource):
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
try:
|
||||
doc = _resolve_source(doc_id, user)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"error": "Document not found or access denied"}), 404
|
||||
)
|
||||
resolved_id = str(doc["id"])
|
||||
try:
|
||||
store = get_vector_store(resolved_id)
|
||||
@@ -278,12 +265,12 @@ class AddChunk(Resource):
|
||||
metadata["token_count"] = token_count
|
||||
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
chunk_id = store.add_chunk(text, metadata)
|
||||
@@ -311,12 +298,12 @@ class DeleteChunk(Resource):
|
||||
chunk_id = request.args.get("chunk_id")
|
||||
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
deleted = store.delete_chunk(chunk_id)
|
||||
@@ -378,12 +365,12 @@ class UpdateChunk(Resource):
|
||||
metadata = {}
|
||||
metadata["token_count"] = token_count
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
from pydantic import ValidationError
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.sources.routes import _resolve_readable_source
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.core.model_utils import get_default_model_id
|
||||
from docsgpt.retriever.dispatcher import Dispatcher
|
||||
from docsgpt.retriever.retriever_creator import RetrieverCreator
|
||||
@@ -102,21 +103,16 @@ class SourceSearch(Resource):
|
||||
# Read access = owner or any team grant (viewer included), matching
|
||||
# the other source read endpoints (wiki pages, graph).
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
# An unresolvable id yields None (→ 404); reaching here means the
|
||||
# An unresolvable id is AccessDenied (404); reaching here means the
|
||||
# lookup itself failed, which is ours, not the caller's.
|
||||
logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Could not resolve source"}), 500
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not found or access denied"}
|
||||
),
|
||||
404,
|
||||
)
|
||||
resolved_id = str(doc["id"])
|
||||
|
||||
# A supplied config is validated exactly as strictly as a saved one (D7
|
||||
|
||||
+142
-177
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import math
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from flask import current_app, jsonify, make_response, redirect, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
@@ -19,11 +20,14 @@ from docsgpt.api.user.tasks import (
|
||||
reingest_source_task,
|
||||
sync_source,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import (
|
||||
can_access,
|
||||
effective_write_owner,
|
||||
visible_with_access,
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.graphrag import graphrag_available
|
||||
from docsgpt.parser.remote.remote_creator import normalize_remote_data
|
||||
@@ -70,6 +74,28 @@ def _get_provider_from_remote_data(remote_data):
|
||||
return None
|
||||
|
||||
|
||||
def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict:
|
||||
"""Add ``access`` + ``allowed_actions`` to a listed source row.
|
||||
|
||||
The source's behaviour ``config`` is dropped when the caller's role may
|
||||
not ``view_config`` (a viewer when the owner turned
|
||||
``viewers_can_see_config`` off).
|
||||
|
||||
Args:
|
||||
entry: The row as the list endpoint builds it.
|
||||
access: ``owner`` / ``editor`` / ``viewer``.
|
||||
switches: The source's owner switches (``settings_many`` output).
|
||||
|
||||
Returns:
|
||||
dict: ``entry`` with the access payload merged in.
|
||||
"""
|
||||
payload = payload_for("source", access, switches)
|
||||
if "view_config" not in payload["allowed_actions"]:
|
||||
entry.pop("config", None)
|
||||
entry.update(payload)
|
||||
return entry
|
||||
|
||||
|
||||
@sources_ns.route("/sources")
|
||||
class CombinedJson(Resource):
|
||||
@api.doc(description="Provide JSON file with combined available indexes")
|
||||
@@ -93,6 +119,7 @@ class CombinedJson(Resource):
|
||||
team_shared = visible_with_access(conn, user, "source")
|
||||
shared_ids = [sid for sid in team_shared if sid not in owned_ids]
|
||||
shared_sources = repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(conn, "source", [*owned_ids, *shared_ids])
|
||||
# list_for_user sorts by created_at DESC; legacy shape sorted by
|
||||
# "date" DESC. Both are monotonic on creation so the ordering is
|
||||
# equivalent for dev; re-sort defensively.
|
||||
@@ -103,7 +130,7 @@ class CombinedJson(Resource):
|
||||
|
||||
def _source_entry(index, *, ownership="user", team_access=None):
|
||||
provider = _get_provider_from_remote_data(index.get("remote_data"))
|
||||
return {
|
||||
entry = {
|
||||
"id": str(index["id"]),
|
||||
"name": index.get("name"),
|
||||
"date": index.get("date"),
|
||||
@@ -121,6 +148,11 @@ class CombinedJson(Resource):
|
||||
"ownership": ownership,
|
||||
"team_access": team_access,
|
||||
}
|
||||
return _with_access(
|
||||
entry,
|
||||
"owner" if ownership == "user" else team_access,
|
||||
switches.get(str(index["id"])),
|
||||
)
|
||||
|
||||
for index in indexes:
|
||||
data.append(_source_entry(index))
|
||||
@@ -178,6 +210,9 @@ class PaginatedSources(Resource):
|
||||
sort_order=sort_order,
|
||||
extra_ids=extra_ids,
|
||||
)
|
||||
switches = settings_many(
|
||||
conn, "source", [str(doc["id"]) for doc in window]
|
||||
)
|
||||
|
||||
paginated_docs = []
|
||||
for doc in window:
|
||||
@@ -185,32 +220,37 @@ class PaginatedSources(Resource):
|
||||
# Owner vs team-shared: a row in the window is the caller's own
|
||||
# when its user_id matches; otherwise it arrived via extra_ids.
|
||||
owned = str(doc.get("user_id")) == str(user)
|
||||
entry = {
|
||||
"id": str(doc["id"]),
|
||||
"name": doc.get("name", ""),
|
||||
"date": doc.get("date", ""),
|
||||
"model": settings.EMBEDDINGS_NAME,
|
||||
"location": "local",
|
||||
"tokens": doc.get("tokens", ""),
|
||||
"retriever": doc.get("retriever", "classic"),
|
||||
"syncFrequency": doc.get("sync_frequency", ""),
|
||||
"provider": provider,
|
||||
"isNested": bool(doc.get("directory_structure")),
|
||||
"type": doc.get("type", "file"),
|
||||
# Lenient read (D7): always emit a fully-defaulted
|
||||
# config so the edit modal can pre-fill, even for a
|
||||
# legacy {} row.
|
||||
"config": SourceConfig.parse(
|
||||
doc.get("config")
|
||||
).model_dump(),
|
||||
# Derived in SourcesRepository.list_for_user.
|
||||
"ingestStatus": doc.get("ingest_status"),
|
||||
"ownership": "user" if owned else "team",
|
||||
"team_access": (
|
||||
None if owned else team_shared.get(str(doc["id"]))
|
||||
),
|
||||
}
|
||||
paginated_docs.append(
|
||||
{
|
||||
"id": str(doc["id"]),
|
||||
"name": doc.get("name", ""),
|
||||
"date": doc.get("date", ""),
|
||||
"model": settings.EMBEDDINGS_NAME,
|
||||
"location": "local",
|
||||
"tokens": doc.get("tokens", ""),
|
||||
"retriever": doc.get("retriever", "classic"),
|
||||
"syncFrequency": doc.get("sync_frequency", ""),
|
||||
"provider": provider,
|
||||
"isNested": bool(doc.get("directory_structure")),
|
||||
"type": doc.get("type", "file"),
|
||||
# Lenient read (D7): always emit a fully-defaulted
|
||||
# config so the edit modal can pre-fill, even for a
|
||||
# legacy {} row.
|
||||
"config": SourceConfig.parse(
|
||||
doc.get("config")
|
||||
).model_dump(),
|
||||
# Derived in SourcesRepository.list_for_user.
|
||||
"ingestStatus": doc.get("ingest_status"),
|
||||
"ownership": "user" if owned else "team",
|
||||
"team_access": (
|
||||
None if owned else team_shared.get(str(doc["id"]))
|
||||
),
|
||||
}
|
||||
_with_access(
|
||||
entry,
|
||||
"owner" if owned else team_shared.get(str(doc["id"])),
|
||||
switches.get(str(doc["id"])),
|
||||
)
|
||||
)
|
||||
response = {
|
||||
"total": total_documents,
|
||||
@@ -242,14 +282,17 @@ class DeleteOldIndexes(Resource):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing required fields"}), 400
|
||||
)
|
||||
# Owner-only unless the owner turned ``editors_can_delete`` on; the
|
||||
# row is deleted as the owner either way.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
doc, ra = load_source(conn, source_id, user, "delete")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"status": "not found"}), 404)
|
||||
owner = ra.owner_id
|
||||
storage = StorageCreator.get_storage()
|
||||
resolved_id = str(doc["id"])
|
||||
|
||||
@@ -283,13 +326,17 @@ class DeleteOldIndexes(Resource):
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
SourcesRepository(conn).delete(resolved_id, user)
|
||||
# The AFTER DELETE trigger drops the source's team grants; the
|
||||
# owner switches have no FK, so clear them here.
|
||||
SourcesRepository(conn).delete(resolved_id, owner)
|
||||
delete_settings(conn, "source", resolved_id)
|
||||
record_event(
|
||||
conn,
|
||||
"source.deleted",
|
||||
actor=user,
|
||||
source_id=resolved_id,
|
||||
name=doc.get("name"),
|
||||
owner=owner if owner != user else None,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -342,21 +389,13 @@ class ManageSync(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = SourcesRepository(conn)
|
||||
doc = repo.get_any(source_id, user)
|
||||
if doc is not None:
|
||||
repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency})
|
||||
else:
|
||||
# Team editor write path (sync_frequency is metadata, no
|
||||
# ingestion side effects). Reingest/sync triggers stay
|
||||
# owner-only pending a cost/side-effect decision.
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
repo.update(source_id, owner, {"sync_frequency": sync_frequency})
|
||||
# Owner or team editor; the write lands as the owner.
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
SourcesRepository(conn).update(
|
||||
str(doc["id"]), ra.owner_id, {"sync_frequency": sync_frequency}
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating sync frequency: {err}", exc_info=True
|
||||
@@ -391,21 +430,15 @@ class SyncSource(Resource):
|
||||
# source_id (owner-agnostic), so dispatching as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if doc is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid source ID"}), 400
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
owner = ra.owner_id
|
||||
source_type = doc.get("type", "")
|
||||
if source_type and source_type.startswith("connector"):
|
||||
return make_response(
|
||||
@@ -469,12 +502,9 @@ class ReingestSource(Resource):
|
||||
# (owner-agnostic), so dispatching as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if doc is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error looking up source: {err}", exc_info=True
|
||||
@@ -482,10 +512,7 @@ class ReingestSource(Resource):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid source ID"}), 400
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
# Drop the stale chunk-progress row so the sources list stops
|
||||
# deriving a 'failed' status; reingest never rewrites it itself.
|
||||
@@ -548,11 +575,7 @@ class DirectoryStructure(Resource):
|
||||
return make_response(jsonify({"error": "Document ID is required"}), 400)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, doc_id, user)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"error": "Document not found or access denied"}), 404
|
||||
)
|
||||
doc, _ra = load_source(conn, doc_id, user, "use")
|
||||
directory_structure = doc.get("directory_structure", {})
|
||||
base_path = doc.get("file_path", "")
|
||||
|
||||
@@ -579,6 +602,8 @@ class DirectoryStructure(Resource):
|
||||
),
|
||||
200,
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
f"Error retrieving directory structure: {e}", exc_info=True
|
||||
@@ -636,23 +661,9 @@ class SourceConfigResource(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = SourcesRepository(conn)
|
||||
# Resolve the owner to write AS: ``user`` when they own the
|
||||
# source, the real owner when ``user`` holds a team ``editor``
|
||||
# grant. A viewer / no-access resolves to None → 403.
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = repo.get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; the write lands as the owner.
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
owner = ra.owner_id
|
||||
# Ingest-time fields (config.chunking) only take effect after a
|
||||
# re-ingest (D8); compare against the current config to decide.
|
||||
current_config = SourceConfig.parse(doc.get("config"))
|
||||
@@ -683,6 +694,8 @@ class SourceConfigResource(Resource):
|
||||
repo.update(
|
||||
str(doc["id"]), owner, {"config": new_config.model_dump()}
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating source config for {source_id}: {err}", exc_info=True
|
||||
@@ -734,20 +747,6 @@ def _unsupported_retrieval_warnings(config) -> list:
|
||||
return warnings
|
||||
|
||||
|
||||
def _resolve_readable_source(conn, source_id, user):
|
||||
"""Return a source dict the caller may READ, or None.
|
||||
|
||||
Read access = owner or any team grant (viewer/editor). Resolves the row
|
||||
without ownership scoping only after the grant check passes.
|
||||
"""
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
if not can_access(conn, "source", source_id, user):
|
||||
return None
|
||||
return SourcesRepository(conn).get_by_id(source_id)
|
||||
|
||||
|
||||
def _wiki_page_node(page):
|
||||
return {
|
||||
"path": page.get("path"),
|
||||
@@ -886,12 +885,10 @@ class WikiPages(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
pages = WikiPagesRepository(conn).list_for_source(str(doc["id"]))
|
||||
directory_structure = doc.get("directory_structure") or {}
|
||||
except Exception as err:
|
||||
@@ -934,12 +931,10 @@ class WikiPage(Resource):
|
||||
)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
page = WikiPagesRepository(conn).get_by_path(str(doc["id"]), path)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -977,20 +972,12 @@ class WikiPage(Resource):
|
||||
expected_version = data.get("expected_version")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
try:
|
||||
page = WikiPagesRepository(conn).upsert(
|
||||
@@ -1074,20 +1061,12 @@ class ConvertSourceToWiki(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
# A mid-ingest source has an incomplete directory structure, so
|
||||
# it could be mis-detected as blank and wrongly enabled inline.
|
||||
@@ -1194,20 +1173,12 @@ class EnableSourceGraphRAG(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
cfg = SourceConfig.parse(doc.get("config"))
|
||||
repo = SourcesRepository(conn)
|
||||
@@ -1314,12 +1285,10 @@ class SourceGraph(Resource):
|
||||
limit = None
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -1449,12 +1418,10 @@ class SourceGraphNodes(Resource):
|
||||
type_key = request.args.get("type")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -1500,12 +1467,10 @@ class SourceGraphNode(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
|
||||
@@ -14,7 +14,8 @@ from sqlalchemy import text as sql_text
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.source_ids import derive_source_id as _derive_source_id
|
||||
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
|
||||
@@ -721,22 +722,10 @@ class ManageSourceFiles(Resource):
|
||||
# (owner-agnostic), so running the ops as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
source = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if source is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
source = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if not source:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Source not found or access denied",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
source, ra = load_source(conn, source_id, user, "edit")
|
||||
owner = ra.owner_id
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error finding source: {err}", exc_info=True)
|
||||
return make_response(
|
||||
|
||||
@@ -13,13 +13,10 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
@@ -89,22 +86,14 @@ def effective_write_owner(
|
||||
...)`` repo methods (which match on ``WHERE id AND user_id = :owner``) without
|
||||
a separate ownerless write path. None means viewer-only or no access → the
|
||||
route should answer 403/404. Delete is never authorized here — owner-only.
|
||||
|
||||
A thin wrapper over :func:`resource_access.resolve`; new code should call
|
||||
``resource_access.require`` with a specific action instead.
|
||||
"""
|
||||
if owns_resource(conn, resource_type, resource_id, user_id):
|
||||
return user_id
|
||||
# Past the ownership check, only canonical-UUID resources can carry a team
|
||||
# grant; a legacy/non-UUID id can't, and casting it would poison the txn.
|
||||
if not looks_like_uuid(resource_id):
|
||||
ra = resolve(conn, resource_type, resource_id, user_id)
|
||||
if ra is None or ra.access not in ("owner", "editor"):
|
||||
return None
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
if not grants:
|
||||
return None
|
||||
if TeamScopeRepository(conn).can_write(user_id, resource_type, resource_id):
|
||||
# All grant rows carry the same denormalised owner_id.
|
||||
return grants[0].get("owner_id")
|
||||
return None
|
||||
return ra.owner_id
|
||||
|
||||
|
||||
def can_access(
|
||||
@@ -116,9 +105,9 @@ def can_access(
|
||||
``source_id`` to an agent): you may reference what you own or what a team has
|
||||
shared with you directly. Transitive access *through* a shared agent is a
|
||||
separate, run-time concept and is intentionally NOT gated here.
|
||||
|
||||
A thin wrapper over :func:`resource_access.resolve`.
|
||||
"""
|
||||
if not resource_id:
|
||||
return True
|
||||
if owns_resource(conn, resource_type, resource_id, user_id):
|
||||
return True
|
||||
return TeamScopeRepository(conn).can_read(user_id, resource_type, resource_id)
|
||||
return resolve(conn, resource_type, resource_id, user_id) is not None
|
||||
@@ -6,9 +6,12 @@ Authorization model (two planes, see ``team_authz.py``):
|
||||
- Team detail / member list / grant list require team membership.
|
||||
- Member management and team edit require ``team_admin``.
|
||||
- Team deletion and owner transfer are owner-only (a global ``admin`` overrides).
|
||||
- Sharing a resource requires the caller to OWN it (dispatched by resource_type)
|
||||
and be a member of the target team. Sharing is additive visibility — the
|
||||
resource's owner is never changed.
|
||||
- Sharing a resource requires the ``share`` action on it (the owner, or an
|
||||
editor when the owner turned on ``editors_can_share``; see
|
||||
``resource_access.py``) and membership of the target team. Unsharing needs
|
||||
``share`` (no membership required) or ``team_admin`` of the team. Sharing is
|
||||
additive visibility — the resource's owner is never changed.
|
||||
- The team owner can't be demoted or removed; they transfer ownership first.
|
||||
|
||||
``team_id`` always comes from the URL path (never the body) — enforced by
|
||||
``require_team_role`` and by reading ``team_id`` as a route kwarg here.
|
||||
@@ -22,14 +25,25 @@ import uuid
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
|
||||
from docsgpt.api.user.resource_access import (
|
||||
RESOURCE_TYPES,
|
||||
AccessDenied,
|
||||
ResourceAccess,
|
||||
build,
|
||||
public_settings,
|
||||
require,
|
||||
set_settings,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_authz import (
|
||||
has_team_role,
|
||||
team_admin_required,
|
||||
team_member_required,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import is_valid_resource_type, owns_resource
|
||||
from docsgpt.api.user.team_sharing import is_valid_resource_type
|
||||
from docsgpt.events.publisher import publish_user_event
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -44,6 +58,7 @@ from docsgpt.storage.db.repositories.team_members import (
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
@@ -62,6 +77,99 @@ def _current_user() -> str | None:
|
||||
return token.get("sub") if isinstance(token, dict) else None
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (404 not visible, 403 not allowed)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _team_payload(team: dict, user: str | None) -> dict:
|
||||
"""Add ``is_owner`` so the UI can gate owner-only actions (delete, transfer)."""
|
||||
team["is_owner"] = bool(user) and team.get("owner_id") == user
|
||||
return team
|
||||
|
||||
|
||||
# Name + owner of each shareable resource, looked up unscoped by id (the grant
|
||||
# row already proves it was shared; the caller's own access is computed below).
|
||||
_RESOURCE_ROW_SQL = {
|
||||
"agent": "SELECT id, name, user_id FROM agents WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"tool": (
|
||||
"SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name) AS name, "
|
||||
"user_id FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _resource_rows(conn, grants: list[dict]) -> dict[tuple[str, str], dict]:
|
||||
"""``(type, id) -> {name, user_id}`` for every resource the grants point at."""
|
||||
ids_by_type: dict[str, set[str]] = {}
|
||||
for g in grants:
|
||||
ids_by_type.setdefault(g["resource_type"], set()).add(str(g["resource_id"]))
|
||||
out: dict[tuple[str, str], dict] = {}
|
||||
for rtype, ids in ids_by_type.items():
|
||||
sql = _RESOURCE_ROW_SQL.get(rtype)
|
||||
if not sql:
|
||||
continue
|
||||
for rid, name, owner in conn.execute(text(sql), {"ids": list(ids)}).fetchall():
|
||||
out[(rtype, str(rid))] = {"name": name, "user_id": owner}
|
||||
return out
|
||||
|
||||
|
||||
def _caller_access(
|
||||
conn, user: str, grants: list[dict], rows: dict[tuple[str, str], dict]
|
||||
) -> dict[tuple[str, str], ResourceAccess]:
|
||||
"""The caller's live access to each granted resource, in a few bulk queries.
|
||||
|
||||
Same answer as ``resource_access.resolve`` per resource (owner by the
|
||||
resource's ``user_id``, else the strongest team grant reaching the caller),
|
||||
without four queries per row.
|
||||
"""
|
||||
scope = TeamScopeRepository(conn)
|
||||
out: dict[tuple[str, str], ResourceAccess] = {}
|
||||
for rtype in {g["resource_type"] for g in grants}:
|
||||
ids = sorted({str(g["resource_id"]) for g in grants if g["resource_type"] == rtype})
|
||||
via_teams = scope.visible_with_access(user, rtype)
|
||||
settings = settings_many(conn, rtype, ids)
|
||||
for rid in ids:
|
||||
row = rows.get((rtype, rid))
|
||||
if row is None:
|
||||
continue # dangling grant: the resource is gone
|
||||
if row["user_id"] == user:
|
||||
level = "owner"
|
||||
else:
|
||||
level = via_teams.get(rid)
|
||||
if level is None:
|
||||
continue
|
||||
out[(rtype, rid)] = build(rtype, rid, level, row["user_id"], settings[rid])
|
||||
return out
|
||||
|
||||
|
||||
def _user_labels(conn, user_ids: set[str]) -> dict[str, str]:
|
||||
"""``user_id -> email`` for the users on file with an email."""
|
||||
ids = [u for u in user_ids if u]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
|
||||
"AND email IS NOT NULL AND email <> ''"
|
||||
),
|
||||
{"ids": ids},
|
||||
).fetchall()
|
||||
return {uid: email for uid, email in rows}
|
||||
|
||||
|
||||
def _valid_resource(resource_type, resource_id) -> bool:
|
||||
"""A known shareable type and a canonical-UUID id (grants are UUID-only)."""
|
||||
return (
|
||||
is_valid_resource_type(resource_type)
|
||||
and bool(resource_id)
|
||||
and isinstance(resource_id, str)
|
||||
and looks_like_uuid(resource_id)
|
||||
)
|
||||
|
||||
|
||||
# Metadata keys naming the user a team event acted on, most specific first.
|
||||
# Lets ``_audit`` fill ``target_id`` without every call site repeating it.
|
||||
_TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner")
|
||||
@@ -218,6 +326,7 @@ class Teams(Resource):
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
teams = TeamsRepository(conn).list_for_user(user)
|
||||
teams = [_team_payload(t, user) for t in teams]
|
||||
return make_response(jsonify({"success": True, "teams": teams}), 200)
|
||||
except Exception as err:
|
||||
logger.error("List teams failed: %s", err, exc_info=True)
|
||||
@@ -243,6 +352,7 @@ class Teams(Resource):
|
||||
)
|
||||
_audit(conn, user, "team.create", team_id=team["id"], name=name)
|
||||
team["member_role"] = ROLE_TEAM_ADMIN
|
||||
_team_payload(team, user)
|
||||
return make_response(jsonify({"success": True, "team": team}), 201)
|
||||
except Exception as err:
|
||||
logger.error("Create team failed: %s", err, exc_info=True)
|
||||
@@ -263,6 +373,7 @@ class Team(Resource):
|
||||
members = TeamMembersRepository(conn)
|
||||
team["members"] = members.list_members(team_id)
|
||||
team["member_role"] = members.role_for(user, team_id)
|
||||
_team_payload(team, user)
|
||||
return make_response(jsonify({"success": True, "team": team}), 200)
|
||||
except Exception as err:
|
||||
logger.error("Get team failed: %s", err, exc_info=True)
|
||||
@@ -370,6 +481,10 @@ class TeamMember(Resource):
|
||||
return {"success": False, "message": "invalid role"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
if role == ROLE_TEAM_MEMBER:
|
||||
blocked = self._owner_guard(conn, team_id, member_id, "demote")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
members = TeamMembersRepository(conn)
|
||||
if role == ROLE_TEAM_MEMBER and self._would_orphan_admins(
|
||||
members, team_id, member_id
|
||||
@@ -403,6 +518,9 @@ class TeamMember(Resource):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
try:
|
||||
with db_session() as conn:
|
||||
blocked = self._owner_guard(conn, team_id, member_id, "remove")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
members = TeamMembersRepository(conn)
|
||||
if self._would_orphan_admins(members, team_id, member_id):
|
||||
return {
|
||||
@@ -423,6 +541,38 @@ class TeamMember(Resource):
|
||||
logger.error("Remove member failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@staticmethod
|
||||
def _owner_guard(conn, team_id: str, member_id: str, change: str):
|
||||
"""Refuse to demote or remove the team owner.
|
||||
|
||||
Another admin gets 403; the owner themselves gets 400 telling them to
|
||||
transfer ownership first (the team would otherwise have an owner who
|
||||
isn't an admin, or isn't a member at all).
|
||||
|
||||
Args:
|
||||
conn: Open connection.
|
||||
team_id: Team from the URL path.
|
||||
member_id: The member being changed.
|
||||
change: ``"demote"`` or ``"remove"`` (for the message).
|
||||
|
||||
Returns:
|
||||
A response to return, or None when the change may proceed.
|
||||
"""
|
||||
team = TeamsRepository(conn).get(team_id)
|
||||
if not team or team.get("owner_id") != member_id:
|
||||
return None
|
||||
if member_id == _current_user():
|
||||
message = (
|
||||
"Transfer team ownership to another member before you leave the team"
|
||||
if change == "remove"
|
||||
else "Transfer team ownership to another member before you step down as admin"
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
||||
verb = "removed" if change == "remove" else "demoted"
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"The team owner can't be {verb}"}), 403
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _would_orphan_admins(
|
||||
members: TeamMembersRepository, team_id: str, member_id: str
|
||||
@@ -443,21 +593,66 @@ class TeamMember(Resource):
|
||||
class TeamGrants(Resource):
|
||||
@team_member_required
|
||||
def get(self, team_id):
|
||||
"""List resources shared with this team. Requires membership."""
|
||||
"""List resources shared with this team. Requires membership.
|
||||
|
||||
Each row carries the resource's name, owner and people labels (email
|
||||
when on file, else null) and ``caller`` — the caller's own live
|
||||
``{access, allowed_actions}`` on that resource (null if none). A plain
|
||||
member sees whole-team grants and grants aimed at them; a team_admin,
|
||||
or someone with ``share`` on the resource, sees every grant.
|
||||
"""
|
||||
user = _current_user()
|
||||
token = getattr(request, "decoded_token", None)
|
||||
resource_type = request.args.get("resource_type")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_team(
|
||||
team_id, resource_type
|
||||
)
|
||||
return make_response(jsonify({"success": True, "grants": grants}), 200)
|
||||
team_role = TeamMembersRepository(conn).role_for(user, team_id)
|
||||
sees_all = team_role == ROLE_TEAM_ADMIN or has_role(token, ROLE_ADMIN)
|
||||
rows = _resource_rows(conn, grants)
|
||||
access = _caller_access(conn, user, grants, rows)
|
||||
visible = []
|
||||
for g in grants:
|
||||
key = (g["resource_type"], str(g["resource_id"]))
|
||||
ra = access.get(key)
|
||||
target = g.get("target_user_id")
|
||||
if not (sees_all or not target or target == user or (ra and ra.can("share"))):
|
||||
continue
|
||||
row = rows.get(key) or {}
|
||||
g["resource_name"] = row.get("name")
|
||||
g["owner_id"] = row.get("user_id") or g.get("owner_id")
|
||||
g["caller"] = ra.payload() if ra else None
|
||||
visible.append(g)
|
||||
labels = _user_labels(
|
||||
conn,
|
||||
{
|
||||
u
|
||||
for g in visible
|
||||
for u in (g.get("owner_id"), g.get("target_user_id"), g.get("granted_by"))
|
||||
if u
|
||||
},
|
||||
)
|
||||
for g in visible:
|
||||
g["owner_label"] = labels.get(g.get("owner_id"))
|
||||
g["target_user_label"] = labels.get(g.get("target_user_id"))
|
||||
g["granted_by_label"] = labels.get(g.get("granted_by"))
|
||||
return make_response(
|
||||
jsonify({"success": True, "grants": visible, "team_role": team_role}), 200
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error("List grants failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@team_member_required
|
||||
def post(self, team_id):
|
||||
"""Share a resource the caller OWNS with this team (additive visibility)."""
|
||||
"""Share a resource with this team, or change an existing grant's level.
|
||||
|
||||
Needs ``share`` on the resource (the owner, or an editor when the owner
|
||||
turned on ``editors_can_share``) and membership of the team. The grant
|
||||
records the real owner as ``owner_id`` and the caller as ``granted_by``.
|
||||
"""
|
||||
user = _current_user()
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = data.get("resource_type")
|
||||
@@ -465,20 +660,18 @@ class TeamGrants(Resource):
|
||||
access_level = data.get("access_level", "viewer")
|
||||
# None → share with the whole team; a sub → share with that one member.
|
||||
target_user_id = (data.get("target_user_id") or "").strip() or None
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
if access_level not in _VALID_ACCESS_LEVELS:
|
||||
return {"success": False, "message": "invalid access_level"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
# Ownership is the security boundary: dispatch by resource_type so
|
||||
# a mismatched type/id can't register a bogus grant.
|
||||
if not owns_resource(conn, resource_type, resource_id, user):
|
||||
return {"success": False, "message": "Not the resource owner"}, 403
|
||||
# ``require`` dispatches by resource_type, so a mismatched
|
||||
# type/id can't register a bogus grant (the table has no FK).
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# A per-member share target must actually be a member of the team.
|
||||
if target_user_id and not TeamMembersRepository(conn).is_member(
|
||||
target_user_id, team_id
|
||||
@@ -487,8 +680,8 @@ class TeamGrants(Resource):
|
||||
grant = TeamResourceGrantsRepository(conn).grant(
|
||||
team_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
owner_id=user,
|
||||
ra.resource_id,
|
||||
owner_id=ra.owner_id,
|
||||
granted_by=user,
|
||||
access_level=access_level,
|
||||
target_user_id=target_user_id,
|
||||
@@ -512,15 +705,21 @@ class TeamGrants(Resource):
|
||||
logger.error("Share resource failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@team_member_required
|
||||
def delete(self, team_id):
|
||||
"""Unshare a resource. Allowed for the resource owner or a team_admin.
|
||||
"""Unshare a resource from this team.
|
||||
|
||||
Identifiers come from query params (some proxies strip DELETE bodies),
|
||||
with a JSON-body fallback for older clients.
|
||||
Allowed with ``share`` on the resource — no membership needed, so an
|
||||
owner who left the team can still pull their resource back — or for a
|
||||
team_admin of this team. ``target_user_id`` picks one member's grant
|
||||
(absent → the whole-team grant). Identifiers come from query params
|
||||
(some proxies strip DELETE bodies), with a JSON-body fallback.
|
||||
"""
|
||||
user = _current_user()
|
||||
token = getattr(request, "decoded_token", None)
|
||||
if not user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}), 401
|
||||
)
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = request.args.get("resource_type") or data.get("resource_type")
|
||||
resource_id = request.args.get("resource_id") or data.get("resource_id")
|
||||
@@ -528,17 +727,15 @@ class TeamGrants(Resource):
|
||||
target_user_id = (
|
||||
request.args.get("target_user_id") or data.get("target_user_id") or ""
|
||||
).strip() or None
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
is_owner = owns_resource(conn, resource_type, resource_id, user)
|
||||
if not is_owner and not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
try:
|
||||
require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied:
|
||||
if not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
revoked = TeamResourceGrantsRepository(conn).revoke(
|
||||
team_id, resource_type, resource_id, target_user_id=target_user_id
|
||||
)
|
||||
@@ -602,26 +799,25 @@ class TeamOwnerTransfer(Resource):
|
||||
@teams_ns.route("/resource_shares")
|
||||
class ResourceShares(Resource):
|
||||
def get(self):
|
||||
"""List the teams a resource the caller OWNS is shared with.
|
||||
"""List the teams a resource is shared with. Needs ``share`` on it.
|
||||
|
||||
Powers the share dialog (show current shares + unshare). Owner-only so a
|
||||
non-owner can't enumerate a resource's sharing graph.
|
||||
Powers the share dialog (current shares + unshare), so only someone who
|
||||
may change the sharing can enumerate it: 404 when the resource isn't
|
||||
visible, 403 when the caller's role can't share.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
resource_type = request.args.get("resource_type")
|
||||
resource_id = request.args.get("resource_id")
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
if not owns_resource(conn, resource_type, resource_id, user):
|
||||
return {"success": False, "message": "Not the resource owner"}, 403
|
||||
try:
|
||||
require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
shares = TeamResourceGrantsRepository(conn).list_for_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
@@ -631,6 +827,83 @@ class ResourceShares(Resource):
|
||||
return {"success": False}, 400
|
||||
|
||||
|
||||
def _settings_response(ra: ResourceAccess):
|
||||
"""The ``resource_settings`` body: switches plus the caller's access."""
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"resource_type": ra.resource_type,
|
||||
"resource_id": ra.resource_id,
|
||||
"settings": public_settings(ra.resource_type, ra.settings),
|
||||
**ra.payload(),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@teams_ns.route("/resource_settings")
|
||||
class ResourceSettings(Resource):
|
||||
def get(self):
|
||||
"""A resource's sharing switches. Anyone with access may read them.
|
||||
|
||||
Query: ``resource_type``, ``resource_id``. Returns ``settings`` as
|
||||
``[{key, value, default}]`` in display order, plus the caller's
|
||||
``access`` and ``allowed_actions``.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
resource_type = request.args.get("resource_type")
|
||||
resource_id = request.args.get("resource_id")
|
||||
if resource_type not in RESOURCE_TYPES or not resource_id:
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "use")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
return _settings_response(ra)
|
||||
except Exception as err:
|
||||
logger.error("Get resource settings failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
def put(self):
|
||||
"""Change a resource's sharing switches. Needs ``manage_settings`` (owner).
|
||||
|
||||
Body: ``{"resource_type", "resource_id", "settings": {key: bool}}``.
|
||||
An unknown key or a non-boolean value is a 400. Returns the GET shape.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = data.get("resource_type")
|
||||
resource_id = data.get("resource_id")
|
||||
changes = data.get("settings")
|
||||
if resource_type not in RESOURCE_TYPES or not resource_id or not isinstance(resource_id, str):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
if not isinstance(changes, dict):
|
||||
return {"success": False, "message": "settings must be an object"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "manage_settings")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
try:
|
||||
merged = set_settings(conn, resource_type, ra.resource_id, changes, user)
|
||||
except ValueError as bad:
|
||||
return {"success": False, "message": str(bad)}, 400
|
||||
updated = build(resource_type, ra.resource_id, ra.access, ra.owner_id, merged)
|
||||
return _settings_response(updated)
|
||||
except Exception as err:
|
||||
logger.error("Update resource settings failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
|
||||
@teams_ns.route("/admin/teams")
|
||||
class AllTeams(Resource):
|
||||
method_decorators = []
|
||||
|
||||
+157
-90
@@ -7,7 +7,15 @@ from flask_restx import Namespace, Resource, fields
|
||||
|
||||
from docsgpt.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.tools.routes import transform_actions
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.api.user.tools.routes import (
|
||||
_CREDENTIALS_FOR_NEW_SERVER,
|
||||
_MCP_CREDENTIAL_AUTH_TYPES,
|
||||
_mcp_host_changed,
|
||||
denied_response,
|
||||
transform_actions,
|
||||
)
|
||||
from docsgpt.cache import get_redis_instance
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
@@ -75,12 +83,60 @@ def _validate_mcp_server_url(config: dict) -> None:
|
||||
raise ValueError(f"Invalid server URL: {exc}") from exc
|
||||
|
||||
|
||||
_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account"
|
||||
|
||||
|
||||
def _existing_mcp_context(tool_id, user, config):
|
||||
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
|
||||
|
||||
With no ``tool_id`` the caller acts on their own new server. With one,
|
||||
the caller needs ``edit_credentials`` on that tool and everything runs as
|
||||
its owner. Stored secrets are write-only, so an empty secret field reuses
|
||||
the stored one while the host is unchanged; a new host never inherits them.
|
||||
|
||||
Returns:
|
||||
``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask
|
||||
response (404 / 400) to return as is.
|
||||
|
||||
Raises:
|
||||
AccessDenied: the caller can't see the tool (404) or can't change
|
||||
its credentials (403).
|
||||
"""
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
if not tool_id:
|
||||
return None, user, True, False, auth_credentials
|
||||
with db_readonly() as conn:
|
||||
ra = require(conn, "tool", tool_id, user, "edit_credentials")
|
||||
existing_doc = UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
if not existing_doc or existing_doc.get("name") != "mcp_tool":
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found or access denied"}), 404,
|
||||
)
|
||||
existing_config = existing_doc.get("config") or {}
|
||||
moved = _mcp_host_changed(config, existing_config)
|
||||
auth_type = config.get("auth_type", "none")
|
||||
new_secret_keys = set(auth_credentials) - {"api_key_header"}
|
||||
if moved and auth_type in _MCP_CREDENTIAL_AUTH_TYPES and not new_secret_keys:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": _CREDENTIALS_FOR_NEW_SERVER}), 400
|
||||
)
|
||||
credentials = dict(auth_credentials)
|
||||
existing_encrypted = None if moved else existing_config.get("encrypted_credentials")
|
||||
if existing_encrypted:
|
||||
credentials = {**decrypt_credentials(existing_encrypted, ra.owner_id), **auth_credentials}
|
||||
return existing_doc, ra.owner_id, ra.access == "owner", moved, credentials
|
||||
|
||||
|
||||
@tools_mcp_ns.route("/mcp_server/test")
|
||||
class TestMCPServerConfig(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"MCPServerTestModel",
|
||||
{
|
||||
"id": fields.String(
|
||||
required=False,
|
||||
description="Stored tool to test with (empty secrets reuse its stored ones)",
|
||||
),
|
||||
"config": fields.Raw(
|
||||
required=True, description="MCP server configuration to test"
|
||||
),
|
||||
@@ -111,11 +167,20 @@ class TestMCPServerConfig(Resource):
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
_existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx
|
||||
if not is_owner and config.get("auth_type") == "oauth":
|
||||
# An OAuth flow would store tokens under the editor's account
|
||||
# (and its popup event goes to that account), not the owner's.
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403
|
||||
)
|
||||
test_config = config.copy()
|
||||
test_config["auth_credentials"] = auth_credentials
|
||||
|
||||
mcp_tool = MCPTool(config=test_config, user_id=user)
|
||||
mcp_tool = MCPTool(config=test_config, user_id=owner_id)
|
||||
result = mcp_tool.test_connection()
|
||||
|
||||
if result.get("requires_oauth"):
|
||||
@@ -148,6 +213,8 @@ class TestMCPServerConfig(Resource):
|
||||
"tools": result.get("tools", []),
|
||||
}
|
||||
return make_response(jsonify(safe_result), 200)
|
||||
except AccessDenied as e:
|
||||
return denied_response(e)
|
||||
except ValueError as e:
|
||||
current_app.logger.warning(f"Invalid MCP server test request: {e}")
|
||||
return make_response(
|
||||
@@ -207,13 +274,55 @@ class MCPServerSave(Resource):
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
# An existing id is always an update of THAT row, written as its
|
||||
# owner; it never falls through to creating a copy for the caller.
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
existing_doc, owner_id, is_owner, moved, merged_credentials = ctx
|
||||
existing_config = (existing_doc or {}).get("config") or {}
|
||||
auth_type = config.get("auth_type", "none")
|
||||
mcp_config = config.copy()
|
||||
mcp_config["auth_credentials"] = auth_credentials
|
||||
mcp_config["auth_credentials"] = merged_credentials
|
||||
keep_actions = False
|
||||
|
||||
if auth_type == "oauth":
|
||||
if not config.get("oauth_task_id"):
|
||||
if config.get("oauth_task_id"):
|
||||
if not is_owner:
|
||||
# The OAuth flow stores tokens under the account that
|
||||
# ran it; reconnecting as the owner is owner-only.
|
||||
return make_response(
|
||||
jsonify({
|
||||
"success": False,
|
||||
"message": _ONLY_OWNER_RECONNECTS,
|
||||
}),
|
||||
403,
|
||||
)
|
||||
redis_client = get_redis_instance()
|
||||
manager = MCPOAuthManager(redis_client)
|
||||
result = manager.get_oauth_status(
|
||||
config["oauth_task_id"], user
|
||||
)
|
||||
if not result.get("status") == "completed":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "OAuth failed or not completed. Please try authorizing again.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
actions_metadata = result.get("tools", [])
|
||||
elif (
|
||||
existing_doc is not None
|
||||
and not moved
|
||||
and existing_config.get("auth_type") == "oauth"
|
||||
):
|
||||
# Editing an already-connected server: keep its tools.
|
||||
actions_metadata = existing_doc.get("actions") or []
|
||||
keep_actions = True
|
||||
else:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
@@ -223,24 +332,8 @@ class MCPServerSave(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
redis_client = get_redis_instance()
|
||||
manager = MCPOAuthManager(redis_client)
|
||||
result = manager.get_oauth_status(
|
||||
config["oauth_task_id"], user
|
||||
)
|
||||
if not result.get("status") == "completed":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "OAuth failed or not completed. Please try authorizing again.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
actions_metadata = result.get("tools", [])
|
||||
elif auth_type == "none" or auth_credentials:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=user)
|
||||
elif auth_type == "none" or merged_credentials:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
|
||||
mcp_tool.discover_tools()
|
||||
actions_metadata = mcp_tool.get_actions_metadata()
|
||||
else:
|
||||
@@ -249,30 +342,10 @@ class MCPServerSave(Resource):
|
||||
)
|
||||
storage_config = config.copy()
|
||||
|
||||
tool_id = data.get("id")
|
||||
existing_doc = None
|
||||
existing_encrypted = None
|
||||
if tool_id:
|
||||
with db_readonly() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
existing_doc = repo.get_any(tool_id, user)
|
||||
if existing_doc and existing_doc.get("name") == "mcp_tool":
|
||||
existing_encrypted = (existing_doc.get("config") or {}).get(
|
||||
"encrypted_credentials"
|
||||
)
|
||||
else:
|
||||
existing_doc = None
|
||||
|
||||
if auth_credentials:
|
||||
if existing_encrypted:
|
||||
existing_secrets = decrypt_credentials(existing_encrypted, user)
|
||||
existing_secrets.update(auth_credentials)
|
||||
auth_credentials = existing_secrets
|
||||
if merged_credentials:
|
||||
storage_config["encrypted_credentials"] = encrypt_credentials(
|
||||
auth_credentials, user
|
||||
merged_credentials, owner_id
|
||||
)
|
||||
elif existing_encrypted:
|
||||
storage_config["encrypted_credentials"] = existing_encrypted
|
||||
|
||||
for field in [
|
||||
"api_key",
|
||||
@@ -283,58 +356,54 @@ class MCPServerSave(Resource):
|
||||
"redirect_uri",
|
||||
]:
|
||||
storage_config.pop(field, None)
|
||||
transformed_actions = transform_actions(actions_metadata)
|
||||
# Kept actions already carry the owner's on/off and approval flags.
|
||||
transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
|
||||
|
||||
display_name = data["displayName"]
|
||||
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
|
||||
status_bool = bool(data.get("status", True))
|
||||
fields_out = {
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
}
|
||||
updated_message = (
|
||||
f"MCP server updated successfully! Discovered {len(transformed_actions)} tools."
|
||||
)
|
||||
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
if existing_doc:
|
||||
repo.update(
|
||||
str(existing_doc["id"]), user,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
"status": status_bool,
|
||||
},
|
||||
)
|
||||
if existing_doc is not None:
|
||||
# ``status`` is the owner's own chat switch; an editor's
|
||||
# save doesn't flip it.
|
||||
if is_owner:
|
||||
fields_out["status"] = status_bool
|
||||
repo.update(str(existing_doc["id"]), owner_id, fields_out)
|
||||
saved_id = str(existing_doc["id"])
|
||||
response_data = {
|
||||
"success": True,
|
||||
"id": saved_id,
|
||||
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"message": updated_message,
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
else:
|
||||
fields_out["status"] = status_bool
|
||||
# Fall back to find_by_user_and_name — the original
|
||||
# dual-write path also ran an existence check before
|
||||
# deciding between insert and update.
|
||||
existing_by_name = repo.find_by_user_and_name(user, "mcp_tool")
|
||||
if tool_id is None and existing_by_name and (
|
||||
if existing_by_name and (
|
||||
(existing_by_name.get("config") or {}).get("server_url")
|
||||
== storage_config.get("server_url")
|
||||
):
|
||||
repo.update(
|
||||
str(existing_by_name["id"]), user,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
"status": status_bool,
|
||||
},
|
||||
)
|
||||
repo.update(str(existing_by_name["id"]), user, fields_out)
|
||||
saved_id = str(existing_by_name["id"])
|
||||
response_data = {
|
||||
"success": True,
|
||||
"id": saved_id,
|
||||
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"message": updated_message,
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
else:
|
||||
@@ -355,18 +424,9 @@ class MCPServerSave(Resource):
|
||||
"message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
if tool_id and existing_doc is None:
|
||||
# Client requested update on a non-existent tool id.
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Tool not found or access denied",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
return make_response(jsonify(response_data), 200)
|
||||
except AccessDenied as e:
|
||||
return denied_response(e)
|
||||
except ValueError as e:
|
||||
current_app.logger.warning(f"Invalid MCP server save request: {e}")
|
||||
return make_response(
|
||||
@@ -455,6 +515,13 @@ class MCPAuthStatus(Resource):
|
||||
tools_repo = UserToolsRepository(conn)
|
||||
sessions_repo = ConnectorSessionsRepository(conn)
|
||||
all_tools = tools_repo.list_for_user(user)
|
||||
owned_ids = {str(t["id"]) for t in all_tools}
|
||||
# Team-shared MCP servers the caller can see run with the
|
||||
# owner's connection, so their status is the owner's.
|
||||
shared_ids = [
|
||||
tid for tid in visible_with_access(conn, user, "tool") if tid not in owned_ids
|
||||
]
|
||||
all_tools = all_tools + tools_repo.list_by_ids(shared_ids)
|
||||
mcp_tools = [t for t in all_tools if t.get("name") == "mcp_tool"]
|
||||
if not mcp_tools:
|
||||
return make_response(
|
||||
@@ -472,7 +539,7 @@ class MCPAuthStatus(Resource):
|
||||
if server_url:
|
||||
parsed = urlparse(server_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
oauth_server_urls[tool_id] = base_url
|
||||
oauth_server_urls[tool_id] = (tool.get("user_id") or user, base_url)
|
||||
else:
|
||||
statuses[tool_id] = "needs_auth"
|
||||
else:
|
||||
@@ -484,9 +551,9 @@ class MCPAuthStatus(Resource):
|
||||
# and the URL in ``server_url``; reuse the repo's
|
||||
# per-URL accessor rather than an ad-hoc $in query.
|
||||
url_has_tokens: dict = {}
|
||||
for base_url in set(oauth_server_urls.values()):
|
||||
for owner_id, base_url in set(oauth_server_urls.values()):
|
||||
session = sessions_repo.get_by_user_and_server_url(
|
||||
user, base_url,
|
||||
owner_id, base_url,
|
||||
)
|
||||
tokens = (
|
||||
(session or {}).get("session_data", {}) or {}
|
||||
@@ -494,13 +561,13 @@ class MCPAuthStatus(Resource):
|
||||
# MCP code also stashes tokens into token_info on
|
||||
# the row; consider either present as "connected".
|
||||
token_info = (session or {}).get("token_info") or {}
|
||||
url_has_tokens[base_url] = bool(
|
||||
url_has_tokens[(owner_id, base_url)] = bool(
|
||||
tokens.get("access_token")
|
||||
or token_info.get("access_token")
|
||||
)
|
||||
|
||||
for tool_id, base_url in oauth_server_urls.items():
|
||||
if url_has_tokens.get(base_url):
|
||||
for tool_id, key in oauth_server_urls.items():
|
||||
if url_has_tokens.get(key):
|
||||
statuses[tool_id] = "connected"
|
||||
else:
|
||||
statuses[tool_id] = "needs_auth"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Tool management routes."""
|
||||
|
||||
import copy
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.agents.default_tools import (
|
||||
builtin_agent_tools_for_management,
|
||||
@@ -13,12 +18,21 @@ from docsgpt.agents.default_tools import (
|
||||
is_synthesized_tool_id,
|
||||
WORKFLOW_ONLY_BUILTINS,
|
||||
)
|
||||
from docsgpt.agents.tool_executor import API_TOOL_SECRET_SECTIONS, API_TOOL_SECRETS_KEY
|
||||
from docsgpt.agents.tools.spec_parser import parse_spec
|
||||
from docsgpt.agents.tools.tool_manager import ToolManager
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
ResourceAccess,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
@@ -26,6 +40,9 @@ from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.artifacts import ArtifactsRepository
|
||||
from docsgpt.storage.db.repositories.notes import NotesRepository
|
||||
from docsgpt.storage.db.repositories.todos import TodosRepository
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -166,6 +183,238 @@ def _merge_secrets_on_update(new_config, existing_config, config_requirements, u
|
||||
return storage_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access + secrets helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
_CREDENTIALS_FOR_NEW_SERVER = "Enter credentials for the new server"
|
||||
_FORBIDDEN_MESSAGE = "Your access to this item doesn't allow that"
|
||||
_MCP_CREDENTIAL_AUTH_TYPES = {"api_key", "bearer", "basic"}
|
||||
_META_KEYS = ("name", "displayName", "customName", "description", "actions")
|
||||
|
||||
|
||||
class CredentialsRequired(Exception):
|
||||
"""A save moved a tool to a new host without supplying new secrets."""
|
||||
|
||||
|
||||
def denied_response(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (403 or 404)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def check_action(ra: ResourceAccess, action: str) -> None:
|
||||
"""Raise a 403 :class:`AccessDenied` unless ``ra`` allows ``action``."""
|
||||
if not ra.can(action):
|
||||
raise AccessDenied(403, _FORBIDDEN_MESSAGE)
|
||||
|
||||
|
||||
def url_host(url: Any) -> str:
|
||||
"""Lower-cased host of ``url`` ('' when it has none)."""
|
||||
try:
|
||||
return (urlparse(str(url or "").strip()).hostname or "").lower()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def _has_value(value: Any) -> bool:
|
||||
return value is not None and value != ""
|
||||
|
||||
|
||||
def _secret_props(action: Any):
|
||||
"""Yield ``(section, param, spec)`` for an api_tool action's secret-bearing params."""
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
for section in API_TOOL_SECRET_SECTIONS:
|
||||
block = action.get(section)
|
||||
props = block.get("properties") if isinstance(block, dict) else None
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
for param, spec in props.items():
|
||||
if isinstance(spec, dict):
|
||||
yield section, param, spec
|
||||
|
||||
|
||||
def _stored_api_tool_secrets(config: dict, owner_id: str) -> dict:
|
||||
"""Decrypted ``{action: {section: {param: value}}}`` plus legacy plaintext values."""
|
||||
config = config or {}
|
||||
blob = config.get(API_TOOL_SECRETS_KEY)
|
||||
secrets: dict = decrypt_credentials(blob, owner_id) if blob else {}
|
||||
for name, action in (config.get("actions") or {}).items():
|
||||
for section, param, spec in _secret_props(action):
|
||||
value = spec.get("value")
|
||||
if _has_value(value):
|
||||
secrets.setdefault(name, {}).setdefault(section, {}).setdefault(param, value)
|
||||
return secrets
|
||||
|
||||
|
||||
def mask_api_tool_config(config: dict) -> dict:
|
||||
"""Copy of an api_tool config with header/query values blanked and ``has_value`` set.
|
||||
|
||||
Args:
|
||||
config: The stored ``user_tools.config``.
|
||||
|
||||
Returns:
|
||||
A deep copy safe to return to any caller: the encrypted blob is dropped
|
||||
and every header / query-param entry has ``value: ""`` plus ``has_value``.
|
||||
"""
|
||||
out = copy.deepcopy(config or {})
|
||||
out.pop(API_TOOL_SECRETS_KEY, None)
|
||||
for action in (out.get("actions") or {}).values():
|
||||
for _section, _param, spec in _secret_props(action):
|
||||
spec["has_value"] = _has_value(spec.get("value")) or bool(spec.get("has_value"))
|
||||
spec["value"] = ""
|
||||
return out
|
||||
|
||||
|
||||
def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: str) -> dict:
|
||||
"""Move api_tool header/query values into an encrypted blob keyed by the owner.
|
||||
|
||||
An incoming entry with a value replaces the stored one; an empty value with
|
||||
``has_value`` keeps it (legacy plaintext values included); anything else
|
||||
clears it. When an action's URL host changes the stored values are not
|
||||
carried over.
|
||||
|
||||
Args:
|
||||
new_config: The config the client sent.
|
||||
existing_config: The stored config (``{}`` on create).
|
||||
owner_id: The tool row's ``user_id`` — the encryption key owner.
|
||||
|
||||
Returns:
|
||||
The config to persist.
|
||||
|
||||
Raises:
|
||||
CredentialsRequired: a host changed, the client asked to keep a value,
|
||||
and there is nothing to keep.
|
||||
"""
|
||||
existing_config = existing_config or {}
|
||||
stored = _stored_api_tool_secrets(existing_config, owner_id)
|
||||
old_actions = existing_config.get("actions") or {}
|
||||
out = copy.deepcopy(new_config or {})
|
||||
out.pop(API_TOOL_SECRETS_KEY, None)
|
||||
sealed: dict = {}
|
||||
for name, action in (out.get("actions") or {}).items():
|
||||
old = old_actions.get(name) if isinstance(old_actions, dict) else None
|
||||
moved = isinstance(old, dict) and url_host(old.get("url")) != url_host(
|
||||
action.get("url") if isinstance(action, dict) else ""
|
||||
)
|
||||
prior = {} if moved else stored.get(name, {})
|
||||
for section, param, spec in _secret_props(action):
|
||||
value = spec.get("value")
|
||||
if _has_value(value):
|
||||
kept = value
|
||||
elif spec.get("has_value"):
|
||||
kept = (prior.get(section) or {}).get(param)
|
||||
if not _has_value(kept):
|
||||
if moved:
|
||||
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
|
||||
kept = None
|
||||
else:
|
||||
kept = None
|
||||
spec["value"] = ""
|
||||
spec["has_value"] = kept is not None
|
||||
if kept is not None:
|
||||
sealed.setdefault(name, {}).setdefault(section, {})[param] = kept
|
||||
if sealed:
|
||||
out[API_TOOL_SECRETS_KEY] = encrypt_credentials(sealed, owner_id)
|
||||
return out
|
||||
|
||||
|
||||
def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict) -> bool:
|
||||
"""Whether an api_tool config change touches endpoints or secrets.
|
||||
|
||||
Descriptions, parameter schemas and on/off flags are ``edit``; a new or
|
||||
changed URL, a new action (it brings a URL), a secret value or any other
|
||||
config key is ``edit_credentials``.
|
||||
"""
|
||||
new_config = new_config or {}
|
||||
existing_config = existing_config or {}
|
||||
ignore = ("actions", API_TOOL_SECRETS_KEY, "has_encrypted_credentials")
|
||||
if {k: v for k, v in new_config.items() if k not in ignore} != {
|
||||
k: v for k, v in existing_config.items() if k not in ignore
|
||||
}:
|
||||
return True
|
||||
old_actions = existing_config.get("actions") or {}
|
||||
for name, action in (new_config.get("actions") or {}).items():
|
||||
old = old_actions.get(name)
|
||||
if not isinstance(old, dict) or not isinstance(action, dict):
|
||||
return True
|
||||
if str(action.get("url") or "") != str(old.get("url") or ""):
|
||||
return True
|
||||
for _section, _param, spec in _secret_props(action):
|
||||
if _has_value(spec.get("value")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
|
||||
old_url = (existing_config or {}).get("server_url")
|
||||
return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
|
||||
|
||||
|
||||
def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict:
|
||||
"""Validate-free merge of an incoming config with the stored one, as the owner.
|
||||
|
||||
Handles the three secret stores: ``config_requirements`` secrets
|
||||
(``encrypted_credentials``), api_tool header/query values, and the MCP
|
||||
host-change rule (a new server host drops stored credentials).
|
||||
|
||||
Raises:
|
||||
CredentialsRequired: the MCP host changed and no new secret arrived.
|
||||
"""
|
||||
owner_id = tool_doc["user_id"]
|
||||
existing_config = tool_doc.get("config") or {}
|
||||
if tool_doc.get("name") == "api_tool":
|
||||
return _seal_api_tool_secrets(new_config, existing_config, owner_id)
|
||||
moved = tool_doc.get("name") == "mcp_tool" and _mcp_host_changed(new_config, existing_config)
|
||||
if moved:
|
||||
existing_config = {k: v for k, v in existing_config.items() if k != "encrypted_credentials"}
|
||||
final = _merge_secrets_on_update(new_config, existing_config, config_requirements, owner_id)
|
||||
if moved and final.get("auth_type") in _MCP_CREDENTIAL_AUTH_TYPES and not final.get(
|
||||
"encrypted_credentials"
|
||||
):
|
||||
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
|
||||
return final
|
||||
|
||||
|
||||
def _shared_via(conn: Connection, user_id: str, tool_ids: list) -> dict:
|
||||
"""``tool_id -> team name`` through which a grant reaches ``user_id``."""
|
||||
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT ON (g.resource_id) g.resource_id, t.name
|
||||
FROM team_resource_grants g
|
||||
JOIN team_members m ON m.team_id = g.team_id
|
||||
JOIN teams t ON t.id = g.team_id
|
||||
WHERE m.user_id = :user_id AND g.resource_type = 'tool'
|
||||
AND g.resource_id = ANY(CAST(:ids AS uuid[]))
|
||||
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
|
||||
ORDER BY g.resource_id, (g.access_level = 'editor') DESC, t.name
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "ids": ids},
|
||||
).fetchall()
|
||||
return {str(r[0]): r[1] for r in rows}
|
||||
|
||||
|
||||
def _owner_labels(conn: Connection, owner_ids) -> dict:
|
||||
"""``user_id -> email`` for the owners that have one on record."""
|
||||
ids = sorted({str(o) for o in owner_ids if o})
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text("SELECT user_id, email FROM users WHERE user_id = ANY(:ids) AND email IS NOT NULL"),
|
||||
{"ids": ids},
|
||||
).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
def _load_owned_row(conn: Connection, ra: ResourceAccess) -> Optional[dict]:
|
||||
"""The tool row behind ``ra``, read as its owner."""
|
||||
return UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
|
||||
|
||||
def transform_actions(actions_metadata):
|
||||
"""Set default flags on action metadata for storage.
|
||||
|
||||
@@ -239,6 +488,10 @@ class GetTools(Resource):
|
||||
team_shared = visible_with_access(conn, user, "tool")
|
||||
shared_ids = [tid for tid in team_shared if tid not in owned_ids]
|
||||
shared_rows = tools_repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(conn, "tool", [*owned_ids, *shared_ids])
|
||||
prefs = UserToolPreferencesRepository(conn).in_chat_many(user, shared_ids)
|
||||
shared_via = _shared_via(conn, user, shared_ids)
|
||||
owner_labels = _owner_labels(conn, [r.get("user_id") for r in shared_rows])
|
||||
user_tools = []
|
||||
|
||||
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
|
||||
@@ -257,14 +510,25 @@ class GetTools(Resource):
|
||||
):
|
||||
tool_copy["config"]["has_encrypted_credentials"] = True
|
||||
tool_copy["config"].pop("encrypted_credentials", None)
|
||||
if tool_copy.get("name") == "api_tool":
|
||||
# Header / query-param values are secrets for everyone.
|
||||
tool_copy["config"] = mask_api_tool_config(tool_copy.get("config") or {})
|
||||
tool_copy["ownership"] = ownership
|
||||
return tool_copy
|
||||
|
||||
for row in rows:
|
||||
user_tools.append(_shape_tool(row))
|
||||
shaped = _shape_tool(row)
|
||||
shaped.update(payload_for("tool", "owner", switches.get(str(row["id"]))))
|
||||
shaped["in_chat"] = bool(row.get("status"))
|
||||
user_tools.append(shaped)
|
||||
for row in shared_rows:
|
||||
tid = str(row["id"])
|
||||
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
|
||||
shaped["team_access"] = team_shared.get(str(row["id"]))
|
||||
shaped["team_access"] = team_shared.get(tid)
|
||||
shaped.update(payload_for("tool", team_shared.get(tid), switches.get(tid)))
|
||||
shaped["in_chat"] = prefs.get(tid, False)
|
||||
shaped["shared_via"] = shared_via.get(tid)
|
||||
shaped["owner_label"] = owner_labels.get(row.get("user_id"))
|
||||
user_tools.append(shaped)
|
||||
|
||||
# ``scheduler`` is dual-registered (default chat tool + agent-
|
||||
@@ -275,6 +539,7 @@ class GetTools(Resource):
|
||||
for default_row in default_tools_for_management(user_doc):
|
||||
default_copy = _row_to_api(default_row)
|
||||
default_copy["default"] = True
|
||||
default_copy["in_chat"] = bool(default_copy.get("status"))
|
||||
if default_copy.get("name") in BUILTIN_AGENT_TOOLS:
|
||||
default_copy["builtin"] = True
|
||||
seen_ids.add(str(default_copy["id"]))
|
||||
@@ -386,9 +651,12 @@ class CreateTool(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
storage_config = _encrypt_secret_fields(
|
||||
data["config"], config_requirements, user
|
||||
)
|
||||
if data["name"] == "api_tool":
|
||||
storage_config = _seal_api_tool_secrets(data["config"], {}, user)
|
||||
else:
|
||||
storage_config = _encrypt_secret_fields(
|
||||
data["config"], config_requirements, user
|
||||
)
|
||||
with db_session() as conn:
|
||||
created = UserToolsRepository(conn).create(
|
||||
user,
|
||||
@@ -478,43 +746,47 @@ class UpdateTool(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
if "config" in data and isinstance(data["config"], dict) and "actions" in data["config"]:
|
||||
for action_name in list((data["config"]["actions"] or {}).keys()):
|
||||
if not validate_function_name(action_name):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
|
||||
"param": "tools[].function.name",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
try:
|
||||
update_data: dict = {}
|
||||
for key in ("name", "displayName", "customName", "description", "actions"):
|
||||
for key in _META_KEYS:
|
||||
if key in data:
|
||||
update_data[key] = data[key]
|
||||
if "config" in data:
|
||||
if "actions" in data["config"]:
|
||||
for action_name in list(data["config"]["actions"].keys()):
|
||||
if not validate_function_name(action_name):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
|
||||
"param": "tools[].function.name",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
)
|
||||
with db_session() as conn:
|
||||
ra = require(conn, "tool", data["id"], user, "use")
|
||||
tool_doc = _load_owned_row(conn, ra)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}), 404,
|
||||
)
|
||||
if update_data:
|
||||
check_action(ra, "edit")
|
||||
if "config" in data:
|
||||
tool_name = tool_doc.get("name", data.get("name"))
|
||||
existing_config = tool_doc.get("config", {}) or {}
|
||||
if tool_name == "api_tool" and not _api_tool_config_needs_credentials(
|
||||
data["config"], existing_config
|
||||
):
|
||||
check_action(ra, "edit")
|
||||
else:
|
||||
check_action(ra, "edit_credentials")
|
||||
tool_instance = tool_manager.tools.get(tool_name)
|
||||
config_requirements = (
|
||||
tool_instance.get_config_requirements()
|
||||
if tool_instance
|
||||
else {}
|
||||
tool_instance.get_config_requirements() if tool_instance else {}
|
||||
)
|
||||
existing_config = tool_doc.get("config", {}) or {}
|
||||
has_existing_secrets = "encrypted_credentials" in existing_config
|
||||
|
||||
if config_requirements:
|
||||
validation_errors = _validate_config(
|
||||
data["config"], config_requirements,
|
||||
@@ -529,29 +801,27 @@ class UpdateTool(Resource):
|
||||
}),
|
||||
400,
|
||||
)
|
||||
|
||||
update_data["config"] = _merge_secrets_on_update(
|
||||
data["config"], existing_config, config_requirements, user
|
||||
update_data["config"] = _prepare_tool_config(
|
||||
tool_doc, data["config"], config_requirements
|
||||
)
|
||||
if "status" in data:
|
||||
update_data["status"] = bool(data["status"])
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
|
||||
)
|
||||
else:
|
||||
if "status" in data:
|
||||
update_data["status"] = bool(data["status"])
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
if ra.access == "owner":
|
||||
update_data["status"] = bool(data["status"])
|
||||
else:
|
||||
# A grantee's chat switch is personal; the owner's
|
||||
# ``status`` is the owner's own chat setting.
|
||||
check_action(ra, "use_in_own")
|
||||
UserToolPreferencesRepository(conn).set_in_chat(
|
||||
user, str(tool_doc["id"]), bool(data["status"])
|
||||
)
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
|
||||
if update_data:
|
||||
UserToolsRepository(conn).update(
|
||||
str(tool_doc["id"]), ra.owner_id, _api_to_update_fields(update_data),
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except CredentialsRequired as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 400)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error updating tool: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -596,7 +866,8 @@ class UpdateToolConfig(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
ra = require(conn, "tool", data["id"], user, "edit_credentials")
|
||||
tool_doc = _load_owned_row(conn, ra)
|
||||
if not tool_doc:
|
||||
return make_response(jsonify({"success": False}), 404)
|
||||
|
||||
@@ -633,11 +904,13 @@ class UpdateToolConfig(Resource):
|
||||
400,
|
||||
)
|
||||
|
||||
final_config = _merge_secrets_on_update(
|
||||
data["config"], existing_config, config_requirements, user
|
||||
)
|
||||
final_config = _prepare_tool_config(tool_doc, data["config"], config_requirements)
|
||||
|
||||
repo.update(str(tool_doc["id"]), user, {"config": final_config})
|
||||
repo.update(str(tool_doc["id"]), ra.owner_id, {"config": final_config})
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except CredentialsRequired as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 400)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool config: {err}", exc_info=True
|
||||
@@ -684,20 +957,12 @@ class UpdateToolActions(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if tool_doc:
|
||||
repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]})
|
||||
else:
|
||||
# Team editor write path (secrets stay owner-only — actions
|
||||
# carry no credentials, so editing them is safe).
|
||||
owner = effective_write_owner(conn, "tool", data["id"], user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
)
|
||||
repo.update(data["id"], owner, {"actions": data["actions"]})
|
||||
# ``edit`` covers action on/off, descriptions and approval
|
||||
# (``require_approval``); actions carry no credentials.
|
||||
ra = require(conn, "tool", data["id"], user, "edit")
|
||||
UserToolsRepository(conn).update(ra.resource_id, ra.owner_id, {"actions": data["actions"]})
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool actions: {err}", exc_info=True
|
||||
@@ -753,16 +1018,19 @@ class UpdateToolStatus(Resource):
|
||||
400,
|
||||
)
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
ra = require(conn, "tool", data["id"], user, "use")
|
||||
if ra.access == "owner":
|
||||
UserToolsRepository(conn).update(
|
||||
ra.resource_id, ra.owner_id, {"status": bool(data["status"])},
|
||||
)
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, {"status": bool(data["status"])},
|
||||
)
|
||||
else:
|
||||
# A grantee's "In my chats" switch is personal.
|
||||
check_action(ra, "use_in_own")
|
||||
UserToolPreferencesRepository(conn).set_in_chat(
|
||||
user, ra.resource_id, bool(data["status"])
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool status: {err}", exc_info=True
|
||||
@@ -802,13 +1070,13 @@ class DeleteTool(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}), 404
|
||||
)
|
||||
repo.delete(str(tool_doc["id"]), user)
|
||||
ra = require(conn, "tool", data["id"], user, "delete")
|
||||
# Grants are removed by the ``user_tools`` delete trigger and
|
||||
# chat preferences by FK cascade; the switches have no FK.
|
||||
UserToolsRepository(conn).delete(ra.resource_id, ra.owner_id)
|
||||
delete_settings(conn, "tool", ra.resource_id)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from flask import current_app, request
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.agents.workflows.cel_evaluator import (
|
||||
CelEvaluationError,
|
||||
validate_cel_expression,
|
||||
)
|
||||
from docsgpt.api.user.resource_access import AccessDenied, resolve
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
|
||||
from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
|
||||
@@ -46,6 +48,60 @@ def _resolve_workflow(repo: WorkflowsRepository, workflow_id: str, user_id: str)
|
||||
return repo.get_by_legacy_id(workflow_id, user_id)
|
||||
|
||||
|
||||
def _workflow_access(conn, workflow_id: str, user_id: str, action: str):
|
||||
"""Resolve a workflow the caller may ``action``, and the id to act as.
|
||||
|
||||
The caller's own workflow is always theirs. Otherwise access comes from
|
||||
an agent of the workflow's owner that uses it: ``view`` to read it,
|
||||
``edit`` to change it, ``delete`` to remove it (checked on that agent).
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
workflow_id: Workflow UUID or legacy id.
|
||||
user_id: The caller.
|
||||
action: Agent action required (``view``, ``edit`` or ``delete``).
|
||||
|
||||
Returns:
|
||||
``(workflow, acting_user_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't ``action``.
|
||||
"""
|
||||
repo = WorkflowsRepository(conn)
|
||||
own = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if own is not None:
|
||||
return own, user_id
|
||||
if not looks_like_uuid(str(workflow_id)):
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
workflow = repo.get_by_id(str(workflow_id))
|
||||
if workflow is None:
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
agent_ids = conn.execute(
|
||||
sql_text(
|
||||
"SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"
|
||||
),
|
||||
{"wid": str(workflow["id"]), "owner": workflow["user_id"]},
|
||||
).scalars().all()
|
||||
visible = False
|
||||
for agent_id in agent_ids:
|
||||
ra = resolve(conn, "agent", str(agent_id), user_id)
|
||||
if ra is None:
|
||||
continue
|
||||
visible = True
|
||||
if ra.can(action):
|
||||
return workflow, ra.owner_id
|
||||
if not visible:
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": err.message, "message": err.message}), err.status
|
||||
)
|
||||
|
||||
|
||||
def _write_graph(
|
||||
conn,
|
||||
pg_workflow_id: str,
|
||||
@@ -499,10 +555,10 @@ class WorkflowDetail(Resource):
|
||||
user_id = get_user_id()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, _acting = _workflow_access(conn, workflow_id, user_id, "view")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
pg_workflow_id = str(workflow["id"])
|
||||
graph_version = get_workflow_graph_version(workflow)
|
||||
nodes = WorkflowNodesRepository(conn).find_by_version(
|
||||
@@ -533,21 +589,23 @@ class WorkflowDetail(Resource):
|
||||
nodes_data = data.get("nodes", [])
|
||||
edges_data = data.get("edges", [])
|
||||
|
||||
validation_errors = validate_workflow_structure(
|
||||
nodes_data, edges_data, user_id=user_id
|
||||
)
|
||||
if validation_errors:
|
||||
return error_response(
|
||||
"Workflow validation failed", errors=validation_errors
|
||||
)
|
||||
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, acting = _workflow_access(conn, workflow_id, user_id, "edit")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Validated as the owner: the workflow runs with the owner's
|
||||
# models, so their BYOM ids are the ones that must resolve.
|
||||
validation_errors = validate_workflow_structure(
|
||||
nodes_data, edges_data, user_id=acting
|
||||
)
|
||||
if validation_errors:
|
||||
return error_response(
|
||||
"Workflow validation failed", errors=validation_errors
|
||||
)
|
||||
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
||||
pg_workflow_id = str(workflow["id"])
|
||||
current_graph_version = get_workflow_graph_version(workflow)
|
||||
next_graph_version = current_graph_version + 1
|
||||
@@ -557,7 +615,7 @@ class WorkflowDetail(Resource):
|
||||
nodes_data, edges_data,
|
||||
)
|
||||
repo.update(
|
||||
pg_workflow_id, user_id,
|
||||
pg_workflow_id, acting,
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
@@ -582,11 +640,12 @@ class WorkflowDetail(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, acting = _workflow_access(conn, workflow_id, user_id, "delete")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# ON DELETE CASCADE on workflow_nodes/edges cleans children.
|
||||
repo.delete(str(workflow["id"]), user_id)
|
||||
repo.delete(str(workflow["id"]), acting)
|
||||
except Exception as err:
|
||||
return _workflow_error_response("Failed to delete workflow", err)
|
||||
|
||||
|
||||
Reference in new issue
Block a user