Shared resource use

This commit is contained in:
Pavel committed 2026-09-29 11:43:43 +04:00
1 parent 38bfb19739
commit 7530e54aec
34 files changed
+1322 -101

No files matched your search

+9 -2
View File
@@ -18,6 +18,7 @@ from docsgpt.api.answer.services.stream_processor import (
authorized_prompt_id,
get_prompt,
)
from docsgpt.api.user.resource_access import ref_principal
from docsgpt.core.settings import settings
from docsgpt.quotas.service import QuotaExceededError, QuotaService
from docsgpt.retriever.retriever_creator import RetrieverCreator
@@ -151,7 +152,13 @@ def _run_agent_headless(
source_active: Any = {}
if source_id:
with db_readonly() as conn:
src_row = SourcesRepository(conn).get(str(source_id), owner)
# Owned or team-shared to the owner, else attached by an editor
# who still qualifies; read unscoped once authorized.
src_row = (
SourcesRepository(conn).get_by_id(str(source_id))
if ref_principal(conn, "agent", agent_config, "source", str(source_id))
else None
)
if src_row:
source_active = str(src_row["id"])
retriever_kind = src_row.get("retriever", retriever_kind)
@@ -161,7 +168,7 @@ def _run_agent_headless(
chunks = 6 if raw_chunks in (None, "") else int(raw_chunks)
# Runs as the owner: a prompt they can no longer use (revoked grant,
# deleted) falls back to the default instead of rendering anyway.
prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner)
prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner, agent_config)
user_api_key = agent_config.get("key")
agent_id = _resolve_agent_id(agent_config)
agent_type = agent_config.get("agent_type", "classic")
+14
View File
@@ -505,6 +505,9 @@ class ToolExecutor:
# get_tools() resolves EXACTLY these ids — builtin synthetic ids and
# user_tools rows alike — with no defaults mixed in. None = unscoped.
self.allowed_tool_ids: Optional[List[str]] = None
# Tool id -> the user to resolve it as, for a workflow node's tools
# sponsored by an editor (see resource_access.active_sponsor).
self.tool_principals: Dict[str, str] = {}
self.conversation_id: Optional[str] = None
# Set by the workflow engine for agent nodes so run-scoped tools
# (artifact_generator / code_executor) address artifacts by the
@@ -567,6 +570,8 @@ class ToolExecutor:
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo)
if row is None and str(tid) in self.tool_principals:
row = resolve_tool_by_id(tid, self.tool_principals[str(tid)], user_tools_repo=tools_repo)
if row is None:
logger.warning("tool id %s did not resolve; dropped from scoped toolset", tid)
continue
@@ -588,6 +593,15 @@ class ToolExecutor:
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo)
if row is None:
# A tool the owner can't use runs as the editor who
# attached it, while they still qualify.
# Lazy: docsgpt.api's package import pulls in every route module.
from docsgpt.api.user.resource_access import active_sponsor
sponsor = active_sponsor(conn, "agent", agent_data, "tool", str(tid))
if sponsor:
row = resolve_tool_by_id(tid, sponsor, user_tools_repo=tools_repo)
if row is None:
continue
# Workflow-only builtins (read_document) never resolve for a
+4
View File
@@ -45,6 +45,7 @@ class WorkflowAgent(BaseAgent):
):
super().__init__(*args, **kwargs)
self.workflow_id = workflow_id
self.workflow_row: Optional[Dict[str, Any]] = None
self.workflow_owner = workflow_owner
self._workflow_data = workflow
self._engine: Optional[WorkflowEngine] = None
@@ -195,6 +196,9 @@ class WorkflowAgent(BaseAgent):
if workflow_row is None:
logger.error(f"Workflow {self.workflow_id} not found or inaccessible for user {owner_id}")
return None
# Node tools/sources the owner can't use resolve through its
# ``resource_sponsors`` (see WorkflowEngine).
self.workflow_row = workflow_row
pg_workflow_id = str(workflow_row["id"])
graph_version = workflow_row.get("current_graph_version", 1)
try:
+3
View File
@@ -19,6 +19,7 @@ class _WorkflowNodeMixin:
model_id: str,
api_key: str,
tool_ids: Optional[List[str]] = None,
tool_principals: Optional[Dict[str, str]] = None,
**kwargs,
):
super().__init__(
@@ -34,6 +35,8 @@ class _WorkflowNodeMixin:
# (Artifact / Code Executor / Read Document) and ``user_tools`` rows
# alike, and an empty list means the node's LLM gets no tools.
self.tool_executor.allowed_tool_ids = [str(t) for t in (tool_ids or [])]
# Tools the owner can't use resolve as the editor who attached them.
self.tool_executor.tool_principals = dict(tool_principals or {})
class WorkflowNodeClassicAgent(_WorkflowNodeMixin, ClassicAgent):
+40 -2
View File
@@ -405,6 +405,7 @@ class WorkflowEngine:
"model_user_id": getattr(self.agent, "model_user_id", None),
"api_key": node_api_key,
"tool_ids": node_config.tools,
"tool_principals": self._node_tool_principals(node_config.tools),
"prompt": node_prompt,
"chat_history": self.agent.chat_history,
"decoded_token": self.agent.decoded_token,
@@ -1321,6 +1322,36 @@ class WorkflowEngine:
docs_together = "\n\n".join(docs_together_parts) if docs_together_parts else None
return docs, docs_together
def _node_tool_principals(self, tool_ids) -> Dict[str, str]:
"""Node tool id -> the editor to resolve it as, for sponsored tools.
Only tools with a live sponsor on the workflow appear; the executor
still tries the owner first.
Args:
tool_ids: The node's configured tool ids.
Returns:
dict: ``tool_id -> sponsor`` user id.
"""
workflow_row = getattr(self.agent, "workflow_row", None)
if not tool_ids or not workflow_row or not workflow_row.get("resource_sponsors"):
return {}
from docsgpt.api.user.resource_access import active_sponsor
from docsgpt.storage.db.session import db_readonly
principals: Dict[str, str] = {}
try:
with db_readonly() as conn:
for tid in tool_ids:
sponsor = active_sponsor(conn, "workflow", workflow_row, "tool", str(tid))
if sponsor:
principals[str(tid)] = sponsor
except Exception:
logger.exception("Workflow node tool sponsor lookup failed; using the owner only.")
return {}
return principals
def _authorized_node_sources(self, sources) -> list:
"""Filter a node's configured source ids to those its owner may read.
@@ -1329,7 +1360,9 @@ class WorkflowEngine:
tenant's source id and the retriever — which filters only on
``source_id`` — handed the documents back. Gate on the workflow owner
(not the runner): a shared workflow legitimately reads its owner's
sources, exactly like a shared agent does.
sources, exactly like a shared agent does. A source the owner can't
read still passes while the editor who attached it (its sponsor)
qualifies.
Args:
sources: Source ids from the stored node config.
@@ -1348,14 +1381,19 @@ class WorkflowEngine:
logger.warning("Workflow node sources dropped: no owner to authorize.")
return []
from docsgpt.api.user.resource_access import active_sponsor
from docsgpt.api.user.team_sharing import can_access
from docsgpt.storage.db.session import db_readonly
workflow_row = getattr(self.agent, "workflow_row", None)
allowed = []
try:
with db_readonly() as conn:
for sid in ids:
if sid and can_access(conn, "source", str(sid), owner):
if sid and (
can_access(conn, "source", str(sid), owner)
or active_sponsor(conn, "workflow", workflow_row, "source", str(sid))
):
allowed.append(sid)
else:
logger.warning(
@@ -0,0 +1,40 @@
"""0039 resource sponsors — who vouches for a saved resource the owner can't use.
An agent (and a workflow) runs as its owner, so every source, prompt and tool
it references is authorized against the owner. A team editor who attaches
their own private tool, prompt or source would have it dropped at run time.
``resource_sponsors`` records the editor who attached such a resource, keyed
``"<type>:<id>"`` (e.g. ``{"tool:<uuid>": "bob"}``). At run time the resource
is authorized as that sponsor, provided they can still edit the agent and
still use the resource (``docsgpt/api/user/resource_access.py``).
An empty map means today's behaviour: owner-only authorization.
Idempotent both ways.
Revision ID: 0039_resource_sponsors
Revises: 0038_resource_access_settings
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0039_resource_sponsors"
down_revision: Union[str, None] = "0038_resource_access_settings"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
for table in ("agents", "workflows"):
op.execute(
f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS "
"resource_sponsors JSONB NOT NULL DEFAULT '{}'::jsonb;"
)
def downgrade() -> None:
for table in ("agents", "workflows"):
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS resource_sponsors;")
+31 -10
View File
@@ -112,16 +112,19 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str:
_PROMPT_PRESETS_WITHOUT_ROW = ("reduce",)
def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any:
"""``prompt_id`` if ``principal`` may use it, else ``"default"``.
def authorized_prompt_id(prompt_id: Any, principal: Optional[str], agent: Optional[dict] = None) -> Any:
"""``prompt_id`` if ``principal`` (or the agent's sponsor) may use it, else ``"default"``.
Presets pass through. A custom prompt must be owned by ``principal`` or
reach them through a team grant with ``use`` (checked live); a revoked,
deleted or foreign prompt falls back to the default prompt.
reach them through a team grant with ``use`` (checked live). On an agent
run, a prompt the owner can't use still renders while the editor who
attached it (its sponsor) qualifies. A revoked, deleted or foreign prompt
falls back to the default prompt.
Args:
prompt_id: The configured prompt (preset name, UUID or legacy id).
principal: The agent owner for an agent run, else the caller.
agent: The agent row on an agent run, for its ``resource_sponsors``.
Returns:
The prompt id to render.
@@ -131,20 +134,38 @@ def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any:
pid = str(prompt_id)
if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW:
return prompt_id
from docsgpt.api.user.resource_access import resolve
from docsgpt.api.user.resource_access import active_sponsor, resolve
try:
with db_readonly() as conn:
ra = resolve(conn, "prompt", pid, principal) if principal else None
usable = ra is not None and ra.can("use")
if not usable and agent and agent.get("id"):
usable = active_sponsor(conn, "agent", agent, "prompt", pid) is not None
except Exception:
logger.exception("Prompt access check failed for %s", pid)
ra = None
if ra is not None and ra.can("use"):
usable = False
if usable:
return prompt_id
logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
return "default"
def _agent_source_doc(conn: Any, sources_repo: Any, agent: dict, source_id: Any) -> Optional[dict]:
"""The source row an agent may retrieve from, or None.
Authorized as the owner (owned or team-shared to them), else as the
editor who attached it while they still qualify. Read unscoped once
authorized: an owner-scoped read misses a team-shared source.
"""
from docsgpt.api.user.resource_access import ref_principal
if not ref_principal(conn, "agent", agent, "source", str(source_id)):
logger.info("agent %s source %s not usable; skipped", agent.get("id"), source_id)
return None
return sources_repo.get_by_id(str(source_id))
def _wiki_write_owner(conn: Any, source_id: str, caller: str) -> Optional[str]:
"""The owner id to write a wiki source as, when ``caller`` may edit it."""
from docsgpt.api.user.resource_access import resolve
@@ -720,13 +741,12 @@ class StreamProcessor:
# the legacy ``data["source"]`` slot.
sources_list: list = []
seen: set = set()
owner = agent.get("user_id")
primary_id = agent.get("source_id")
# ``sources`` row may have NULL ``retriever``/``chunks`` —
# fall back to the agent's value (``dict.get`` returns None
# even when the key exists with value None).
if primary_id:
source_doc = sources_repo.get(str(primary_id), owner)
source_doc = _agent_source_doc(conn, sources_repo, agent, primary_id)
if source_doc:
sid = str(source_doc["id"])
data["source"] = sid
@@ -759,7 +779,7 @@ class StreamProcessor:
for sid_raw in agent.get("extra_source_ids") or []:
if not sid_raw:
continue
source_doc = sources_repo.get(str(sid_raw), owner)
source_doc = _agent_source_doc(conn, sources_repo, agent, sid_raw)
if not source_doc:
continue
sid = str(source_doc["id"])
@@ -983,6 +1003,7 @@ class StreamProcessor:
"prompt_id": authorized_prompt_id(
self._agent_data.get("prompt_id", "default"),
self._agent_data.get("user"),
self._agent_data,
),
"agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME),
"user_api_key": effective_key,
+20
View File
@@ -28,11 +28,14 @@ from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.api.user.resource_access import (
AccessDenied,
agent_refs,
delete_settings,
payload_for,
require,
resolve,
settings_many,
sponsor_details,
sponsors_after_save,
)
from docsgpt.api.user.team_sharing import (
can_access,
@@ -536,12 +539,16 @@ class GetAgent(Resource):
try:
user = decoded_token["sub"]
agent = None
sponsored: list = []
with db_readonly() as conn:
# 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)
# Edit-page detail: who vouches for resources the owner can't use.
if agent and ra.can("view"):
sponsored = sponsor_details(conn, "agent", agent)
if not agent:
return {"status": "Not found"}, 404
is_owner = ra.access == "owner"
@@ -552,6 +559,7 @@ class GetAgent(Resource):
resolve_names=True,
access=ra.payload(),
)
data["resource_sponsors"] = sponsored
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
@@ -1383,6 +1391,18 @@ class UpdateAgent(Resource):
403,
)
# A resource the owner can't use runs as the editor who
# attached it (its sponsor); record who that is.
after_save = dict(existing_agent)
for ref_field in ("source_id", "extra_source_ids", "prompt_id", "tools"):
if ref_field in update_fields:
after_save[ref_field] = update_fields[ref_field]
sponsors = sponsors_after_save(
conn, "agent", existing_agent, owner_id, user, agent_refs(after_save)
)
if sponsors != (existing_agent.get("resource_sponsors") or {}):
update_fields["resource_sponsors"] = sponsors
# Guardrails and the pooled quota are policy: an unchanged
# value re-sent by a full-form save is fine, a change needs
# ``edit_policy``.
+217
View File
@@ -21,6 +21,7 @@ revoked grant or membership denies on the next request.
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Iterable, Optional
@@ -36,6 +37,8 @@ from docsgpt.storage.db.repositories.team_resource_grants import (
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
logger = logging.getLogger(__name__)
RESOURCE_TYPES = ("agent", "source", "tool", "prompt")
# Weakest role that may perform each action by default. ``owner`` rows are
@@ -332,3 +335,217 @@ def require(
if not ra.can(action):
raise AccessDenied(403, "Your access to this item doesn't allow that")
return ra
# --- Resource sponsors ------------------------------------------------------
#
# An agent (or workflow) runs as its owner, so a source, prompt or tool it
# references is authorized against the owner. When a team editor attaches one
# the owner can't use, the editor becomes its *sponsor*: the holder row's
# ``resource_sponsors`` maps ``"<type>:<id>"`` to the editor's id, and at run
# time the resource is authorized as the sponsor while they can still edit the
# holder and still use the resource. A tool still runs with its own row's
# credentials (the tool owner's), whoever the principal is.
# Action a principal needs on a referenced resource for a holder to run it.
REF_USE_ACTION = {"source": "use", "prompt": "use", "tool": "use_in_own"}
def sponsor_key(resource_type: str, resource_id: str) -> str:
"""The ``resource_sponsors`` key for one referenced resource."""
return f"{resource_type}:{resource_id}"
def can_use_ref(conn: Connection, resource_type: str, resource_id: str, user_id: Optional[str]) -> bool:
"""Whether ``user_id`` may have ``resource_id`` run inside something they hold.
Args:
conn: Open database connection.
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
user_id: The would-be principal.
Returns:
True when the user owns the resource or a team grant gives them
``use`` (``use_in_own`` for a tool).
"""
if not user_id or not resource_id:
return False
ra = resolve(conn, resource_type, str(resource_id), user_id)
return ra is not None and ra.can(REF_USE_ACTION[resource_type])
def _holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_id: str) -> bool:
"""Whether ``user_id`` may still edit the agent or workflow ``holder``.
A workflow is edited through an agent of its owner that uses it, so the
check is ``edit`` on any such agent (mirrors the workflow routes).
"""
if holder_type == "agent":
ra = resolve(conn, "agent", str(holder["id"]), user_id)
return ra is not None and ra.can("edit")
if holder_type == "workflow":
agent_ids = conn.execute(
text("SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"),
{"wid": str(holder["id"]), "owner": holder.get("user_id")},
).scalars().all()
for agent_id in agent_ids:
ra = resolve(conn, "agent", str(agent_id), user_id)
if ra is not None and ra.can("edit"):
return True
return False
raise ValueError(f"Unknown sponsor holder type: {holder_type}")
def active_sponsor(
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
) -> Optional[str]:
"""The sponsor a holder may run ``resource_id`` as, checked live.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row (needs ``id``, ``user_id``, ``resource_sponsors``).
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
Returns:
The sponsor's id when one is recorded, still edits the holder and
can still use the resource; else None.
"""
if not holder or not resource_id:
return None
sponsor = (holder.get("resource_sponsors") or {}).get(sponsor_key(resource_type, str(resource_id)))
if not sponsor or sponsor == holder.get("user_id"):
return None
try:
if not _holder_editable_by(conn, holder_type, holder, sponsor):
return None
return sponsor if can_use_ref(conn, resource_type, str(resource_id), sponsor) else None
except Exception:
logger.exception("Sponsor check failed for %s %s", resource_type, resource_id)
return None
def ref_principal(
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
) -> Optional[str]:
"""The user a holder's referenced resource is authorized as, or None.
The owner when they may use it (the default), else a live sponsor.
"""
if not holder:
return None
owner = holder.get("user_id")
if can_use_ref(conn, resource_type, str(resource_id), owner):
return owner
return active_sponsor(conn, holder_type, holder, resource_type, resource_id)
def _sponsorable(resource_type: str, resource_id: str) -> bool:
"""Only real rows need a principal: skip presets and builtin tool ids."""
rid = str(resource_id or "")
if not looks_like_uuid(rid):
return False
if resource_type == "tool":
# Lazy: default_tools imports this module lazily too.
from docsgpt.agents.default_tools import is_synthesized_tool_id
return not is_synthesized_tool_id(rid)
return True
def agent_refs(agent: dict) -> list[tuple[str, str]]:
"""The ``(type, id)`` resources an agent row references."""
refs = [("source", str(s)) for s in [agent.get("source_id"), *(agent.get("extra_source_ids") or [])] if s]
if agent.get("prompt_id"):
refs.append(("prompt", str(agent["prompt_id"])))
refs.extend(("tool", str(t)) for t in agent.get("tools") or [] if t)
return refs
def sponsors_after_save(
conn: Connection,
holder_type: str,
holder: Optional[dict],
owner_id: str,
caller: str,
refs: Iterable[tuple[str, str]],
) -> dict[str, str]:
"""The ``resource_sponsors`` map to store after ``caller`` saves ``refs``.
Per referenced resource the owner can't use: a recorded sponsor who still
qualifies is kept; otherwise the caller takes it over when they can use it
(a new attachment, or one whose sponsor lost access); otherwise the old
record is kept so the editor can show who added it. Resources the owner
can use, presets and builtin tools need no sponsor. Removed refs drop out.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row before the save (None when creating it).
owner_id: The holder's owner.
caller: The user saving.
refs: Every ``(type, id)`` the holder references after the save.
Returns:
dict: ``"<type>:<id>" -> user_id``.
"""
previous = (holder or {}).get("resource_sponsors") or {}
out: dict[str, str] = {}
for resource_type, resource_id in refs:
key = sponsor_key(resource_type, resource_id)
if key in out or not _sponsorable(resource_type, resource_id):
continue
if can_use_ref(conn, resource_type, resource_id, owner_id):
continue
if active_sponsor(conn, holder_type, holder, resource_type, resource_id):
out[key] = previous[key]
elif caller != owner_id and can_use_ref(conn, resource_type, resource_id, caller):
out[key] = caller
elif previous.get(key):
out[key] = previous[key]
return out
def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> list[dict]:
"""The holder's sponsored resources for its edit page.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
Returns:
list: ``{type, id, user_id, label, active}`` per sponsored resource;
``label`` is the sponsor's email when on file, ``active`` whether it
runs (the sponsor still edits the holder and can use the resource).
"""
sponsors = holder.get("resource_sponsors") or {}
if not sponsors:
return []
user_ids = sorted({u for u in sponsors.values() if u})
labels = dict(
conn.execute(
text(
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
"AND email IS NOT NULL AND email <> ''"
),
{"ids": user_ids},
).fetchall()
) if user_ids else {}
out = []
for key, user_id in sponsors.items():
resource_type, _, resource_id = key.partition(":")
if resource_type not in REF_USE_ACTION or not resource_id:
continue
out.append(
{
"type": resource_type,
"id": resource_id,
"user_id": user_id,
"label": labels.get(user_id) or user_id,
"active": active_sponsor(conn, holder_type, holder, resource_type, resource_id) is not None,
}
)
return out
+45 -9
View File
@@ -1,6 +1,6 @@
"""Workflow management routes."""
from typing import Any, Dict, List, Optional, Set
from typing import Any, Dict, List, Optional, Set, Tuple
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
@@ -10,7 +10,12 @@ from docsgpt.agents.workflows.cel_evaluator import (
CelEvaluationError,
validate_cel_expression,
)
from docsgpt.api.user.resource_access import AccessDenied, resolve
from docsgpt.api.user.resource_access import (
AccessDenied,
resolve,
sponsor_details,
sponsors_after_save,
)
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
@@ -95,6 +100,30 @@ def _workflow_access(conn, workflow_id: str, user_id: str, action: str):
raise AccessDenied(403, "Your access to this item doesn't allow that")
def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
"""The ``(type, id)`` tools and sources a workflow's agent nodes reference.
Args:
nodes: Nodes as the builder sends them (config under ``data`` or
``data.config``, like the engine reads it).
Returns:
list: ``("tool", id)`` and ``("source", id)`` pairs.
"""
refs: List[Tuple[str, str]] = []
for node in nodes or []:
if not isinstance(node, dict) or node.get("type") != "agent":
continue
data = node.get("data") or {}
cfg = data.get("config") if isinstance(data.get("config"), dict) else data
for resource_type, key in (("tool", "tools"), ("source", "sources")):
values = cfg.get(key) or []
if isinstance(values, (str, int)):
values = [values]
refs.extend((resource_type, str(v)) for v in values if v)
return refs
def _denied(err: AccessDenied):
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
return make_response(
@@ -567,6 +596,7 @@ class WorkflowDetail(Resource):
edges = WorkflowEdgesRepository(conn).find_by_version(
pg_workflow_id, graph_version,
)
sponsored = sponsor_details(conn, "workflow", workflow)
except Exception as err:
return _workflow_error_response("Failed to fetch workflow", err)
@@ -575,6 +605,7 @@ class WorkflowDetail(Resource):
"workflow": serialize_workflow(workflow),
"nodes": [serialize_node(n) for n in nodes],
"edges": [serialize_edge(e) for e in edges],
"resource_sponsors": sponsored,
}
)
@@ -614,14 +645,19 @@ class WorkflowDetail(Resource):
conn, pg_workflow_id, next_graph_version,
nodes_data, edges_data,
)
repo.update(
pg_workflow_id, acting,
{
"name": name,
"description": description,
"current_graph_version": next_graph_version,
},
workflow_fields = {
"name": name,
"description": description,
"current_graph_version": next_graph_version,
}
# A node tool/source the owner can't use runs as the editor
# who attached it (its sponsor); record who that is.
sponsors = sponsors_after_save(
conn, "workflow", workflow, acting, user_id, _node_refs(nodes_data)
)
if sponsors != (workflow.get("resource_sponsors") or {}):
workflow_fields["resource_sponsors"] = sponsors
repo.update(pg_workflow_id, acting, workflow_fields)
WorkflowNodesRepository(conn).delete_other_versions(
pg_workflow_id, next_graph_version,
)
+7 -4
View File
@@ -48,7 +48,7 @@ def _collect_source_ids(agent: Dict[str, Any]) -> List[str]:
def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -> List[str]:
"""Drop source ids the agent's owner may not read.
"""Drop source ids the agent may not read.
``_collect_source_ids`` trusts whatever the agent row carries, and this
service searches those ids directly. That made it the second half of a
@@ -64,20 +64,23 @@ def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -
agent: The agent row resolved from the API key.
source_ids: Ids extracted from that row.
A source the owner can't read still searches while the editor who
attached it (its sponsor) can edit the agent and read the source.
Returns:
list: The subset the agent's owner may read.
list: The subset the agent's owner (or a live sponsor) may read.
"""
owner = agent.get("user_id")
if not owner:
logger.warning("Agent %s has no owner; refusing to search its sources.", agent.get("id"))
return []
from docsgpt.api.user.team_sharing import can_access
from docsgpt.api.user.resource_access import ref_principal
allowed = []
for sid in source_ids:
try:
permitted = can_access(conn, "source", str(sid), owner)
permitted = ref_principal(conn, "agent", agent, "source", str(sid)) is not None
except Exception:
# Fail closed, matching the answer path.
logger.warning("Access check failed for source %s; dropping it.", sid)
+5
View File
@@ -400,6 +400,9 @@ agents_table = Table(
# Per-agent behavior contract (AgentConfig — guardrails today). Empty
# ``{}`` parses to guardrails-disabled.
Column("config", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
# ``"<type>:<id>" -> user_id`` of the editor vouching for a referenced
# resource the owner can't use (migration 0039, see resource_access.py).
Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
Column("default_model_id", Text),
Column("folder_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")),
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL")),
@@ -958,6 +961,8 @@ workflows_table = Table(
Column("name", Text, nullable=False),
Column("description", Text),
Column("current_graph_version", Integer, nullable=False, server_default="1"),
# Same shape as ``agents.resource_sponsors``, for node tools and sources.
Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("legacy_mongo_id", Text),
+5 -5
View File
@@ -50,7 +50,7 @@ class AgentsRepository:
for col, val in kwargs.items():
if col not in _ALLOWED or val is None:
continue
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
# JSONB columns: pass the Python object directly. SQLAlchemy
# Core's JSONB type processor json.dumps it once during
# bind; pre-serialising would double-encode and the value
@@ -234,7 +234,7 @@ class AgentsRepository:
allowed = {
"name", "description", "agent_type", "status", "key", "slug", "source_id",
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
"config",
"config", "resource_sponsors",
"default_model_id", "folder_id", "workflow_id",
"extra_source_ids", "image",
"limited_token_mode", "token_limit",
@@ -248,7 +248,7 @@ class AgentsRepository:
return False
values: dict = {}
for col, val in filtered.items():
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
values[col] = val
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
values[col] = str(val) if val else None
@@ -294,7 +294,7 @@ class AgentsRepository:
allowed = {
"name", "description", "agent_type", "status", "key", "slug", "source_id",
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
"config",
"config", "resource_sponsors",
"default_model_id", "folder_id", "workflow_id",
"extra_source_ids", "image",
"limited_token_mode", "token_limit",
@@ -309,7 +309,7 @@ class AgentsRepository:
values: dict = {}
for col, val in filtered.items():
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
# See note in create(): JSONB columns receive Python
# objects, the type processor handles serialisation.
values[col] = val
+8 -2
View File
@@ -9,6 +9,7 @@ Covers CRUD on workflow metadata:
from __future__ import annotations
import copy
import json
from typing import Optional
from sqlalchemy import Connection, text
@@ -85,12 +86,17 @@ class WorkflowsRepository:
return [row_to_dict(r) for r in result.fetchall()]
def update(self, workflow_id: str, user_id: str, fields: dict) -> bool:
allowed = {"name", "description", "current_graph_version"}
allowed = {"name", "description", "current_graph_version", "resource_sponsors"}
filtered = {k: v for k, v in fields.items() if k in allowed}
if not filtered:
return False
set_parts = [f"{col} = :{col}" for col in filtered]
set_parts = [
f"{col} = CAST(:{col} AS jsonb)" if col == "resource_sponsors" else f"{col} = :{col}"
for col in filtered
]
if "resource_sponsors" in filtered:
filtered["resource_sponsors"] = json.dumps(filtered["resource_sponsors"] or {})
set_parts.append("updated_at = now()")
params = {**filtered, "id": workflow_id, "user_id": user_id}