mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 14:17:08 +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("/")
|
||||
|
||||
Reference in new issue
Block a user