Roles audit and revamp

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

No files matched your search

+23 -2
View File
@@ -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)
+39 -24
View File
@@ -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)
+13 -3
View File
@@ -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
+222 -75
View File
@@ -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:
+49 -7
View File
@@ -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,
+10 -2
View File
@@ -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("/")