mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-17 06:14:25 +00:00
Shared resource use
This commit is contained in:
1 parent
38bfb19739
commit
7530e54aec
34 files changed
+1322
-101
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;")
|
||||
@@ -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,
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in new issue
Block a user