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}
+5 -1
View File
@@ -983,7 +983,11 @@ the current page size, so picking a bigger size never hides the way back
("Page 1 of 1", chevrons off). Counts and numbers in the UI format on the app
language: `formatCount` (`utils/dateTimeUtils`, `Intl` on `intlLocale()`), never
`toLocaleString()` or a raw `{{count}}`; plural keys get the number as `count`
and the formatted text as its own param. The one exception is a headline
and the formatted text as `formatted` (`{{formatted}} members`), a key with no
plural passes only `formatted`, and a count is never baked into the key name
(`memberCountOne`). `jp` and `zhTW` aren't language tags i18next knows, so it
plurals them by English rules: every plural key there has an `_one` form too
(the same text as `_other`), or a count of 1 falls back to English. The one exception is a headline
total that can reach tens of thousands (the chunk count in the Chunks byline
and embedded toolbar): it may use `abbreviateCount` (`components/chunkUtils`,
`Intl` compact notation: "12K", "12 тыс.", "1.2万"; grouped digits where the
+26 -1
View File
@@ -91,7 +91,8 @@ import { resetPreview, selectPreviewStatus } from './agentPreviewSlice';
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
import AgentPreviewSheet from './components/AgentPreviewSheet';
import SectionShell from '../navigation/SectionShell';
import { Agent, ToolSummary } from './types';
import SponsoredResourcesNotice from './components/SponsoredResourcesNotice';
import { Agent, ResourceSponsor, ToolSummary } from './types';
import WorkflowBuilder from './workflow/WorkflowBuilder';
import type { Model } from '../models/types';
@@ -282,6 +283,26 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
[agent.source_details, sourceDocs, t],
);
// Name of a tool/source/prompt that runs with an editor's access, from the
// same owner-agnostic details the pickers show.
const resolveSponsoredName = useCallback(
(sponsor: ResourceSponsor): string => {
if (sponsor.type === 'source') return resolveSourceLabel(sponsor.id);
if (sponsor.type === 'prompt') {
return (
prompts.find((prompt) => prompt.id === sponsor.id)?.name ||
agent.prompt_name ||
t('agents.form.sponsors.unknownItem')
);
}
const tool = selectedTools.find((item) => item.id === sponsor.id);
return tool
? getToolDisplayName(tool)
: t('agents.form.sponsors.unknownItem');
},
[agent.prompt_name, prompts, resolveSourceLabel, selectedTools, t],
);
const sourceItems = useMemo(() => {
const items = toSourcePickerItems(
sourceDocs,
@@ -1185,6 +1206,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{t('agents.form.buttons.add')}
</Button>
</div>
<SponsoredResourcesNotice
agent={agent}
resolveName={resolveSponsoredName}
/>
</div>
</Card>
<Card variant="subtle" padding="lg" className="gap-5">
@@ -0,0 +1,103 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Agent, ResourceSponsor } from '../types';
import SponsoredResourcesNotice from './SponsoredResourcesNotice';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const baseAgent: Agent = {
name: 'A',
description: 'd',
image: '',
source: '',
chunks: '6',
retriever: '',
prompt_id: 'default',
tools: [],
agent_type: 'classic',
status: 'published',
};
const sponsor = (over: Partial<ResourceSponsor>): ResourceSponsor => ({
type: 'tool',
id: 't1',
user_id: 'bob',
label: 'bob@example.com',
active: true,
...over,
});
describe('SponsoredResourcesNotice', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (agent: Agent) => {
await act(async () => {
root.render(
<SponsoredResourcesNotice
agent={agent}
resolveName={(s) => `name-${s.id}`}
/>,
);
});
};
const alerts = () =>
Array.from(container.querySelectorAll('[data-slot="alert"]'));
it('renders nothing for the owner with no sponsored items', async () => {
await render(baseAgent);
expect(container.innerHTML).toBe('');
});
it('tells an editor their own items run with their access', async () => {
await render({ ...baseAgent, access: 'editor', allowed_actions: ['edit'] });
expect(alerts()).toHaveLength(1);
expect(container.textContent).toContain('agents.form.sponsors.attachNote');
expect(container.textContent).not.toContain('publicLinkNote');
});
it('adds the public-link warning when the agent has a link', async () => {
await render({
...baseAgent,
shared: true,
access: 'editor',
allowed_actions: ['edit'],
});
expect(container.textContent).toContain(
'agents.form.sponsors.publicLinkNote',
);
});
it('does not show the attach note to a viewer', async () => {
await render({ ...baseAgent, access: 'viewer', allowed_actions: ['use'] });
expect(container.innerHTML).toBe('');
});
it('splits running and no-longer-running items', async () => {
await render({
...baseAgent,
resource_sponsors: [
sponsor({ id: 't1' }),
sponsor({ id: 's1', type: 'source', active: false }),
],
});
const [running, stopped] = alerts();
expect(running.getAttribute('role')).toBe('note');
expect(running.textContent).toContain('agents.form.sponsors.addedBy');
expect(stopped.getAttribute('data-variant')).toBe('warning');
expect(stopped.textContent).toContain('agents.form.sponsors.unavailable');
});
});
@@ -0,0 +1,91 @@
import { Info, TriangleAlert, UserRound } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { can, isOwner } from '../../utils/accessUtils';
import type { Agent, ResourceSponsor } from '../types';
type SponsoredResourcesNoticeProps = {
agent: Agent;
/** Display name of a sponsored tool, source or prompt. */
resolveName: (sponsor: ResourceSponsor) => string;
};
/** Sponsored items grouped by the person who added them, in first-seen order. */
function groupByPerson(sponsors: ResourceSponsor[]) {
const groups = new Map<string, ResourceSponsor[]>();
for (const sponsor of sponsors) {
const key = sponsor.label || sponsor.user_id;
groups.set(key, [...(groups.get(key) ?? []), sponsor]);
}
return Array.from(groups.entries());
}
/**
* Tools, sources and prompts on the agent that run with an editor's access
* rather than the owner's (`resource_sponsors` from GET /api/get_agent).
*
* An editor sees up front that what they attach runs with their access for
* everyone who uses the agent. Everyone who may view the config sees who
* added what, and which items no longer run because that person lost access.
*/
export default function SponsoredResourcesNotice({
agent,
resolveName,
}: SponsoredResourcesNoticeProps) {
const { t } = useTranslation();
const sponsors = agent.resource_sponsors ?? [];
const showAttachNote = !isOwner(agent) && can(agent, 'edit');
const active = groupByPerson(sponsors.filter((s) => s.active));
const inactive = groupByPerson(sponsors.filter((s) => !s.active));
if (!showAttachNote && sponsors.length === 0) return null;
const names = (items: ResourceSponsor[]) =>
items.map((item) => resolveName(item)).join(', ');
return (
<div className="flex flex-col gap-3 sm:col-span-2">
{showAttachNote && (
<Alert role="note">
<Info />
<AlertDescription>
{t('agents.form.sponsors.attachNote')}
{agent.shared ? ` ${t('agents.form.sponsors.publicLinkNote')}` : ''}
</AlertDescription>
</Alert>
)}
{active.length > 0 && (
<Alert role="note">
<UserRound />
<AlertDescription>
{active.map(([person, items]) => (
<p key={person}>
{t('agents.form.sponsors.addedBy', {
person,
names: names(items),
})}
</p>
))}
</AlertDescription>
</Alert>
)}
{inactive.length > 0 && (
<Alert variant="warning">
<TriangleAlert />
<AlertDescription>
{inactive.map(([person, items]) => (
<p key={person}>
{t('agents.form.sponsors.unavailable', {
person,
names: names(items),
})}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
);
}
+14
View File
@@ -6,6 +6,17 @@ export type ToolSummary = {
display_name: string;
};
/** A tool, source or prompt that runs with the editor's access who added it. */
export type ResourceSponsor = {
type: 'tool' | 'source' | 'prompt';
id: string;
user_id: string;
/** The person's email when on file, else their user id. */
label: string;
/** False once that person can no longer edit the agent or use the item. */
active: boolean;
};
export type Agent = {
id?: string;
name: string;
@@ -41,6 +52,9 @@ export type Agent = {
// names for resources the caller themselves owns).
prompt_name?: string | null;
source_details?: { id: string; name: string | null }[];
// Resources the owner can't use that run as the editor who attached them
// (GET /api/get_agent, callers who may view the config).
resource_sponsors?: ResourceSponsor[];
created_at?: string;
updated_at?: string;
last_used_at?: string;
+16 -6
View File
@@ -676,9 +676,10 @@
"createTeamError": "Team konnte nicht erstellt werden.",
"noTeams": "Noch keine Teams.",
"noDescription": "Keine Beschreibung",
"memberCountOne": "{{count}} Mitglied",
"memberCountOther": "{{count}} Mitglieder",
"sharedCount": "{{count}} geteilt",
"memberCount_one": "{{formatted}} Mitglied",
"memberCount_other": "{{formatted}} Mitglieder",
"sharedCount_one": "{{formatted}} geteilt",
"sharedCount_other": "{{formatted}} geteilt",
"loadError": "Teams konnten nicht geladen werden.",
"roleAdmin": "Admin",
"roleMember": "Mitglied",
@@ -746,8 +747,9 @@
"viaTeam": "über {{team}}",
"removeAccess": "Zugriff entfernen",
"access": "Zugriff",
"showAll": "Alle {{count}} anzeigen",
"andMore": "und {{count}} weitere",
"showAll": "Alle {{formatted}} anzeigen",
"andMore_one": "und {{formatted}} weitere",
"andMore_other": "und {{formatted}} weitere",
"back": "Zurück",
"allSummary": "{{name}} · Teams: {{teams}} · Personen: {{people}}",
"searchAccess": "Personen und Teams suchen…",
@@ -887,7 +889,8 @@
}
},
"sharedList": {
"badgeWithEditors": "{{level}} · +{{count}} Bearbeiter",
"badgeWithEditors_one": "{{level}} · +{{formatted}} Bearbeiter",
"badgeWithEditors_other": "{{level}} · +{{formatted}} Bearbeiter",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Geteilte Ressourcen filtern",
"filter": {
@@ -2007,6 +2010,13 @@
},
"status": {
"published": "Veröffentlicht"
},
"sponsors": {
"attachNote": "Tools, Quellen und Prompts, die du aus deiner eigenen Bibliothek hinzufügst, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff.",
"publicLinkNote": "Dieser Agent hat einen öffentlichen Link, daher kann jeder mit dem Link Antworten daraus erhalten.",
"addedBy": "Hinzugefügt von {{person}}: {{names}}",
"unavailable": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt und hat keinen Zugriff mehr. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
"unknownItem": "Unbenanntes Element"
}
},
"logs": {
+16 -6
View File
@@ -682,9 +682,10 @@
"createTeamError": "Could not create team.",
"noTeams": "No teams yet.",
"noDescription": "No description",
"memberCountOne": "{{count}} member",
"memberCountOther": "{{count}} members",
"sharedCount": "{{count}} shared",
"memberCount_one": "{{formatted}} member",
"memberCount_other": "{{formatted}} members",
"sharedCount_one": "{{formatted}} shared",
"sharedCount_other": "{{formatted}} shared",
"loadError": "Failed to load teams.",
"roleAdmin": "admin",
"roleMember": "member",
@@ -752,8 +753,9 @@
"viaTeam": "via {{team}}",
"removeAccess": "Remove access",
"access": "Access",
"showAll": "Show all {{count}}",
"andMore": "and {{count}} more",
"showAll": "Show all {{formatted}}",
"andMore_one": "and {{formatted}} more",
"andMore_other": "and {{formatted}} more",
"back": "Back",
"allSummary": "{{name}} · Teams: {{teams}} · People: {{people}}",
"searchAccess": "Search people and teams…",
@@ -893,7 +895,8 @@
}
},
"sharedList": {
"badgeWithEditors": "{{level}} · +{{count}} Editor",
"badgeWithEditors_one": "{{level}} · +{{formatted}} editor",
"badgeWithEditors_other": "{{level}} · +{{formatted}} editors",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Filter shared resources",
"filter": {
@@ -2025,6 +2028,13 @@
},
"status": {
"published": "Published"
},
"sponsors": {
"attachNote": "Tools, sources and prompts you add from your own library run with your access for everyone who uses this agent.",
"publicLinkNote": "This agent has a public link, so anyone with the link can get answers from them.",
"addedBy": "Added by {{person}}: {{names}}",
"unavailable": "Not running: {{names}}. {{person}} added them and no longer has access. Remove them or choose ones the owner can use.",
"unknownItem": "Unnamed item"
}
},
"logs": {
+16 -6
View File
@@ -676,9 +676,10 @@
"createTeamError": "No se pudo crear el equipo.",
"noTeams": "Aún no hay equipos.",
"noDescription": "Sin descripción",
"memberCountOne": "{{count}} miembro",
"memberCountOther": "{{count}} miembros",
"sharedCount": "{{count}} compartidos",
"memberCount_one": "{{formatted}} miembro",
"memberCount_other": "{{formatted}} miembros",
"sharedCount_one": "{{formatted}} compartido",
"sharedCount_other": "{{formatted}} compartidos",
"loadError": "No se pudieron cargar los equipos.",
"roleAdmin": "admin",
"roleMember": "miembro",
@@ -746,8 +747,9 @@
"viaTeam": "vía {{team}}",
"removeAccess": "Quitar acceso",
"access": "Acceso",
"showAll": "Ver los {{count}}",
"andMore": "y {{count}} más",
"showAll": "Ver los {{formatted}}",
"andMore_one": "y {{formatted}} más",
"andMore_other": "y {{formatted}} más",
"back": "Atrás",
"allSummary": "{{name}} · Equipos: {{teams}} · Personas: {{people}}",
"searchAccess": "Buscar personas y equipos…",
@@ -887,7 +889,8 @@
}
},
"sharedList": {
"badgeWithEditors": "{{level}} · +{{count}} Editor",
"badgeWithEditors_one": "{{level}} · +{{formatted}} editor",
"badgeWithEditors_other": "{{level}} · +{{formatted}} editores",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Filtrar recursos compartidos",
"filter": {
@@ -2007,6 +2010,13 @@
},
"status": {
"published": "Publicado"
},
"sponsors": {
"attachNote": "Las herramientas, fuentes y prompts que añadas desde tu propia biblioteca se ejecutan con tu acceso para todos los que usen este agente.",
"publicLinkNote": "Este agente tiene un enlace público, así que cualquiera con el enlace puede obtener respuestas de ellos.",
"addedBy": "Añadido por {{person}}: {{names}}",
"unavailable": "Sin ejecutarse: {{names}}. {{person}} los añadió y ya no tiene acceso. Quítalos o elige otros que el propietario pueda usar.",
"unknownItem": "Elemento sin nombre"
}
},
"logs": {
+25 -6
View File
@@ -176,6 +176,7 @@
}
},
"badge": "リビングWiki",
"byline_one": "{{pages}} ページ · {{tokens}} トークン",
"byline_other": "{{pages}} ページ · {{tokens}} トークン",
"explainer": "エージェントは作業しながらこれらのページを読み、書き換えます。",
"explainerEditable": "エージェントは作業しながらこれらのページを読み、書き換えます。このソースを使うエージェントは誰でも編集できます。",
@@ -234,6 +235,7 @@
"byConnections": "(接続数順)",
"typeFilter": "種類で絞り込む",
"otherTypes": "その他: {{types}}",
"otherTypesMore_one": "他{{formatted}}件",
"otherTypesMore_other": "他{{formatted}}件",
"untyped": "種類なし",
"loadFailed": "グラフを読み込めませんでした。",
@@ -245,6 +247,7 @@
"showMore": "もっと見る",
"showLess": "表示を減らす",
"relationships": "関係",
"relationshipsCapped_one": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。",
"relationshipsCapped_other": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。",
"noRelationships": "このエンティティには関係がありません。",
"relatedTo": "関連",
@@ -253,8 +256,11 @@
"sourceChunks": "ソースチャンク",
"showInGraph": "グラフで表示",
"allTypes": "すべての種類",
"chunkCount_one": "{{formatted}}件のチャンク",
"chunkCount_other": "{{formatted}}件のチャンク",
"entityCount_one": "{{formatted}}件のエンティティ",
"entityCount_other": "{{formatted}}件のエンティティ",
"relationshipCount_one": "{{formatted}}件の関係",
"relationshipCount_other": "{{formatted}}件の関係",
"chunk": "チャンク",
"chunkMeta": "{{file}} · {{tokens}} トークン",
@@ -420,9 +426,11 @@
"chunkTitleHint": "回答がこのチャンクを引用するときの名前です。",
"files": "ファイル",
"filesLoadError": "ファイルを読み込めませんでした",
"filesByline_one": "{{files}} ファイル · {{tokens}} トークン",
"filesByline_other": "{{files}} ファイル · {{tokens}} トークン",
"filterFiles": "ファイルを絞り込む",
"searchChunks": "チャンクを検索",
"chunkCount_one": "{{formatted}} 件のチャンク",
"chunkCount_other": "{{formatted}} 件のチャンク",
"editor": {
"write": "編集",
@@ -667,9 +675,10 @@
"createTeamError": "チームを作成できませんでした。",
"noTeams": "チームはまだありません。",
"noDescription": "説明なし",
"memberCountOne": "{{count}}人のメンバー",
"memberCountOther": "{{count}}人のメンバー",
"sharedCount": "{{count}}件の共有",
"memberCount_one": "{{formatted}}人のメンバー",
"memberCount_other": "{{formatted}}人のメンバー",
"sharedCount_one": "{{formatted}}件の共有",
"sharedCount_other": "{{formatted}}件の共有",
"loadError": "チームの読み込みに失敗しました。",
"roleAdmin": "管理者",
"roleMember": "メンバー",
@@ -737,8 +746,9 @@
"viaTeam": "{{team}}経由",
"removeAccess": "アクセス権を削除",
"access": "アクセス",
"showAll": "すべて表示({{count}})",
"andMore": "ほか {{count}} 件",
"showAll": "すべて表示({{formatted}})",
"andMore_one": "ほか {{formatted}} 件",
"andMore_other": "ほか {{formatted}} 件",
"back": "戻る",
"allSummary": "{{name}} · チーム: {{teams}} · ユーザー: {{people}}",
"searchAccess": "ユーザーとチームを検索…",
@@ -878,7 +888,8 @@
}
},
"sharedList": {
"badgeWithEditors": "{{level}} · +{{count}} 編集者",
"badgeWithEditors_one": "{{level}} · +{{formatted}} 編集者",
"badgeWithEditors_other": "{{level}} · +{{formatted}} 編集者",
"meta": "{{type}} · {{owner}}",
"filterLabel": "共有リソースを絞り込む",
"filter": {
@@ -1994,6 +2005,13 @@
},
"status": {
"published": "公開済み"
},
"sponsors": {
"attachNote": "自分のライブラリから追加したツール、ソース、プロンプトは、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。",
"publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。",
"addedBy": "{{person}} が追加: {{names}}",
"unavailable": "実行されていません: {{names}}。追加した {{person}} はアクセス権を失いました。削除するか、オーナーが使用できるものを選んでください。",
"unknownItem": "名前のない項目"
}
},
"logs": {
@@ -2455,6 +2473,7 @@
"empty": "ToDo はまだありません"
},
"note": {
"lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"empty": "空のメモ"
},
+24 -6
View File
@@ -718,9 +718,14 @@
"createTeamError": "Не удалось создать команду.",
"noTeams": "Команд пока нет.",
"noDescription": "Без описания",
"memberCountOne": "{{count}} участник",
"memberCountOther": "Участников: {{count}}",
"sharedCount": "Общих ресурсов: {{count}}",
"memberCount_one": "{{formatted}} участник",
"memberCount_few": "{{formatted}} участника",
"memberCount_many": "{{formatted}} участников",
"memberCount_other": "{{formatted}} участника",
"sharedCount_one": "{{formatted}} общий ресурс",
"sharedCount_few": "{{formatted}} общих ресурса",
"sharedCount_many": "{{formatted}} общих ресурсов",
"sharedCount_other": "{{formatted}} общих ресурса",
"loadError": "Не удалось загрузить команды.",
"roleAdmin": "администратор",
"roleMember": "участник",
@@ -788,8 +793,11 @@
"viaTeam": "через {{team}}",
"removeAccess": "Убрать доступ",
"access": "Доступ",
"showAll": "Показать все ({{count}})",
"andMore": "и ещё {{count}}",
"showAll": "Показать все ({{formatted}})",
"andMore_one": "и ещё {{formatted}}",
"andMore_few": "и ещё {{formatted}}",
"andMore_many": "и ещё {{formatted}}",
"andMore_other": "и ещё {{formatted}}",
"back": "Назад",
"allSummary": "{{name}} · Команды: {{teams}} · Люди: {{people}}",
"searchAccess": "Поиск людей и команд…",
@@ -929,7 +937,10 @@
}
},
"sharedList": {
"badgeWithEditors": "{{level}} · +{{count}} ред.",
"badgeWithEditors_one": "{{level}} · +{{formatted}} редактор",
"badgeWithEditors_few": "{{level}} · +{{formatted}} редактора",
"badgeWithEditors_many": "{{level}} · +{{formatted}} редакторов",
"badgeWithEditors_other": "{{level}} · +{{formatted}} редактора",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Фильтр общих ресурсов",
"filter": {
@@ -2071,6 +2082,13 @@
},
"status": {
"published": "Опубликован"
},
"sponsors": {
"attachNote": "Инструменты, источники и промпты, которые вы добавляете из своей библиотеки, работают с вашим доступом для всех, кто пользуется этим агентом.",
"publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.",
"addedBy": "Добавил(а) {{person}}: {{names}}",
"unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не имеет доступа. Удалите их или выберите те, которые может использовать владелец.",
"unknownItem": "Элемент без названия"
}
},
"logs": {
+25 -6
View File
@@ -176,6 +176,7 @@
}
},
"badge": "動態 Wiki",
"byline_one": "{{pages}} 個頁面 · {{tokens}} 個 token",
"byline_other": "{{pages}} 個頁面 · {{tokens}} 個 token",
"explainer": "代理在工作時會閱讀並改寫這些頁面。",
"explainerEditable": "代理在工作時會閱讀並改寫這些頁面;任何使用此來源的代理都可以編輯它。",
@@ -234,6 +235,7 @@
"byConnections": "(依連結數)",
"typeFilter": "依類型篩選",
"otherTypes": "其他:{{types}}",
"otherTypesMore_one": "另外 {{formatted}} 個",
"otherTypesMore_other": "另外 {{formatted}} 個",
"untyped": "無類型",
"loadFailed": "無法載入圖譜。",
@@ -245,6 +247,7 @@
"showMore": "顯示更多",
"showLess": "收合",
"relationships": "關係",
"relationshipsCapped_one": "顯示 {{total}} 個關係中最強的 {{shown}} 個。",
"relationshipsCapped_other": "顯示 {{total}} 個關係中最強的 {{shown}} 個。",
"noRelationships": "此實體沒有關係。",
"relatedTo": "相關",
@@ -253,8 +256,11 @@
"sourceChunks": "來源區塊",
"showInGraph": "在圖譜中顯示",
"allTypes": "所有類型",
"chunkCount_one": "{{formatted}} 個區塊",
"chunkCount_other": "{{formatted}} 個區塊",
"entityCount_one": "{{formatted}} 個實體",
"entityCount_other": "{{formatted}} 個實體",
"relationshipCount_one": "{{formatted}} 個關係",
"relationshipCount_other": "{{formatted}} 個關係",
"chunk": "區塊",
"chunkMeta": "{{file}} · {{tokens}} Token",
@@ -420,9 +426,11 @@
"chunkTitleHint": "回答引用此文本塊時使用的名稱。",
"files": "檔案",
"filesLoadError": "無法載入檔案",
"filesByline_one": "{{files}} 個檔案 · {{tokens}} Token",
"filesByline_other": "{{files}} 個檔案 · {{tokens}} Token",
"filterFiles": "篩選檔案",
"searchChunks": "搜尋文本塊",
"chunkCount_one": "{{formatted}} 個文本塊",
"chunkCount_other": "{{formatted}} 個文本塊",
"editor": {
"write": "編輯",
@@ -667,9 +675,10 @@
"createTeamError": "無法建立團隊。",
"noTeams": "尚無團隊。",
"noDescription": "無描述",
"memberCountOne": "{{count}} 位成員",
"memberCountOther": "{{count}} 位成員",
"sharedCount": "已分享 {{count}} 項",
"memberCount_one": "{{formatted}} 位成員",
"memberCount_other": "{{formatted}} 位成員",
"sharedCount_one": "已分享 {{formatted}} 項",
"sharedCount_other": "已分享 {{formatted}} 項",
"loadError": "載入團隊失敗。",
"roleAdmin": "管理員",
"roleMember": "成員",
@@ -737,8 +746,9 @@
"viaTeam": "透過 {{team}}",
"removeAccess": "移除存取權",
"access": "存取權",
"showAll": "顯示全部 {{count}} 個",
"andMore": "還有 {{count}} 個",
"showAll": "顯示全部 {{formatted}} 個",
"andMore_one": "還有 {{formatted}} 個",
"andMore_other": "還有 {{formatted}} 個",
"back": "返回",
"allSummary": "{{name}} · 團隊:{{teams}} · 人員:{{people}}",
"searchAccess": "搜尋人員和團隊…",
@@ -878,7 +888,8 @@
}
},
"sharedList": {
"badgeWithEditors": "{{level}} · +{{count}} 編輯者",
"badgeWithEditors_one": "{{level}} · +{{formatted}} 編輯者",
"badgeWithEditors_other": "{{level}} · +{{formatted}} 編輯者",
"meta": "{{type}} · {{owner}}",
"filterLabel": "篩選共用資源",
"filter": {
@@ -1994,6 +2005,13 @@
},
"status": {
"published": "已發佈"
},
"sponsors": {
"attachNote": "你從自己的資料庫新增的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。",
"publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。",
"addedBy": "由 {{person}} 新增:{{names}}",
"unavailable": "未執行:{{names}}。新增它們的 {{person}} 已失去存取權限。請移除它們,或選擇擁有者可以使用的項目。",
"unknownItem": "未命名項目"
}
},
"logs": {
@@ -2455,6 +2473,7 @@
"empty": "尚無待辦事項"
},
"note": {
"lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"empty": "空白筆記"
},
+25 -6
View File
@@ -176,6 +176,7 @@
}
},
"badge": "动态 Wiki",
"byline_one": "{{pages}} 个页面 · {{tokens}} 个 token",
"byline_other": "{{pages}} 个页面 · {{tokens}} 个 token",
"explainer": "代理在工作时会阅读并改写这些页面。",
"explainerEditable": "代理在工作时会阅读并改写这些页面;任何使用此来源的代理都可以编辑它。",
@@ -234,6 +235,7 @@
"byConnections": "(按连接数)",
"typeFilter": "按类型筛选",
"otherTypes": "其他:{{types}}",
"otherTypesMore_one": "另外 {{formatted}} 个",
"otherTypesMore_other": "另外 {{formatted}} 个",
"untyped": "无类型",
"loadFailed": "无法加载图谱。",
@@ -245,6 +247,7 @@
"showMore": "显示更多",
"showLess": "收起",
"relationships": "关系",
"relationshipsCapped_one": "显示 {{total}} 个关系中最强的 {{shown}} 个。",
"relationshipsCapped_other": "显示 {{total}} 个关系中最强的 {{shown}} 个。",
"noRelationships": "此实体没有关系。",
"relatedTo": "相关",
@@ -253,8 +256,11 @@
"sourceChunks": "来源分块",
"showInGraph": "在图谱中显示",
"allTypes": "所有类型",
"chunkCount_one": "{{formatted}} 个分块",
"chunkCount_other": "{{formatted}} 个分块",
"entityCount_one": "{{formatted}} 个实体",
"entityCount_other": "{{formatted}} 个实体",
"relationshipCount_one": "{{formatted}} 个关系",
"relationshipCount_other": "{{formatted}} 个关系",
"chunk": "分块",
"chunkMeta": "{{file}} · {{tokens}} 个令牌",
@@ -420,9 +426,11 @@
"chunkTitleHint": "回答引用此文本块时使用的名称。",
"files": "文件",
"filesLoadError": "无法加载文件",
"filesByline_one": "{{files}} 个文件 · {{tokens}} 个令牌",
"filesByline_other": "{{files}} 个文件 · {{tokens}} 个令牌",
"filterFiles": "筛选文件",
"searchChunks": "搜索文本块",
"chunkCount_one": "{{formatted}} 个文本块",
"chunkCount_other": "{{formatted}} 个文本块",
"editor": {
"write": "编辑",
@@ -667,9 +675,10 @@
"createTeamError": "无法创建团队。",
"noTeams": "暂无团队。",
"noDescription": "无描述",
"memberCountOne": "{{count}} 名成员",
"memberCountOther": "{{count}} 名成员",
"sharedCount": "{{count}} 项共享",
"memberCount_one": "{{formatted}} 名成员",
"memberCount_other": "{{formatted}} 名成员",
"sharedCount_one": "{{formatted}} 项共享",
"sharedCount_other": "{{formatted}} 项共享",
"loadError": "加载团队失败。",
"roleAdmin": "管理员",
"roleMember": "成员",
@@ -737,8 +746,9 @@
"viaTeam": "通过 {{team}}",
"removeAccess": "移除访问权限",
"access": "访问权限",
"showAll": "显示全部 {{count}} 个",
"andMore": "还有 {{count}} 个",
"showAll": "显示全部 {{formatted}} 个",
"andMore_one": "还有 {{formatted}} 个",
"andMore_other": "还有 {{formatted}} 个",
"back": "返回",
"allSummary": "{{name}} · 团队:{{teams}} · 人员:{{people}}",
"searchAccess": "搜索人员和团队…",
@@ -878,7 +888,8 @@
}
},
"sharedList": {
"badgeWithEditors": "{{level}} · +{{count}} 编辑者",
"badgeWithEditors_one": "{{level}} · +{{formatted}} 编辑者",
"badgeWithEditors_other": "{{level}} · +{{formatted}} 编辑者",
"meta": "{{type}} · {{owner}}",
"filterLabel": "筛选共享资源",
"filter": {
@@ -1994,6 +2005,13 @@
},
"status": {
"published": "已发布"
},
"sponsors": {
"attachNote": "你从自己的资料库添加的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。",
"publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。",
"addedBy": "由 {{person}} 添加:{{names}}",
"unavailable": "未运行:{{names}}。添加它们的 {{person}} 已失去访问权限。请移除它们,或选择所有者可以使用的项目。",
"unknownItem": "未命名项目"
}
},
"logs": {
@@ -2455,6 +2473,7 @@
"empty": "暂无待办事项"
},
"note": {
"lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"empty": "空笔记"
},
+1 -1
View File
@@ -223,7 +223,7 @@ describe('Teams page', () => {
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('Key Accounts');
expect(rows[0].textContent).toContain(
'settings.teams.sharedList.badgeWithEditors(level=viewer,count=#1)',
'settings.teams.sharedList.badgeWithEditors(level=viewer,count=1,formatted=#1)',
);
expect(rows[0].textContent).toContain(
'settings.teams.sharedList.meta(type=settings.teams.resourceType.source,owner=Lena Fischer)',
+8 -8
View File
@@ -697,7 +697,8 @@ export default function Teams() {
? t('settings.teams.sharedList.badgeWithEditors', {
interpolation: { escapeValue: false },
level,
count: formatCount(memberEditors),
count: memberEditors,
formatted: formatCount(memberEditors),
})
: level;
}
@@ -863,17 +864,16 @@ export default function Teams() {
<CardFooter className="gap-1.5">
<Users className="size-3.5" aria-hidden />
<span>
{t(
(team.member_count ?? 0) === 1
? 'settings.teams.memberCountOne'
: 'settings.teams.memberCountOther',
{ count: formatCount(team.member_count ?? 0) },
)}
{t('settings.teams.memberCount', {
count: team.member_count ?? 0,
formatted: formatCount(team.member_count ?? 0),
})}
</span>
<span aria-hidden>·</span>
<span>
{t('settings.teams.sharedCount', {
count: formatCount(team.shared_count ?? 0),
count: team.shared_count ?? 0,
formatted: formatCount(team.shared_count ?? 0),
})}
</span>
</CardFooter>
+4 -2
View File
@@ -259,10 +259,12 @@ describe('ShareToTeamModal', () => {
expect(rows).toHaveLength(4);
// Most recent first: user-7, user-6, user-5.
expect(rows[1].textContent).toContain('user-7');
expect(text()).toContain('settings.teams.share.andMore(count=#5)');
expect(text()).toContain(
'settings.teams.share.andMore(count=5,formatted=#5)',
);
const showAll = buttonByText('settings.teams.share.showAll');
expect(showAll?.textContent).toContain(
'settings.teams.share.showAll(count=#8)',
'settings.teams.share.showAll(formatted=#8)',
);
expect(showAll?.getAttribute('data-variant')).toBe('link');
expect(body().querySelector('.max-h-72')).toBeNull();
+3 -2
View File
@@ -934,7 +934,7 @@ export default function ShareToTeamModal({
onClick={openAllStep}
>
{t('settings.teams.share.showAll', {
count: formatCount(shares.length),
formatted: formatCount(shares.length),
})}
<ArrowRight className="size-3" aria-hidden />
</Button>
@@ -948,7 +948,8 @@ export default function ShareToTeamModal({
{isLongList && (
<p className="text-muted-foreground px-4 text-xs">
{t('settings.teams.share.andMore', {
count: formatCount(shares.length - previewShares.length),
count: shares.length - previewShares.length,
formatted: formatCount(shares.length - previewShares.length),
})}
</p>
)}
+3
View File
@@ -195,6 +195,9 @@ def _update_patches(sub, repo, team_access, can_access_mock):
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
*_access_patches(sub, repo, team_access),
patch("docsgpt.api.user.agents.routes.can_access", can_access_mock),
# Sponsor bookkeeping queries the (mocked) connection; covered with a
# real database in tests/api/user/test_resource_sponsors.py.
patch("docsgpt.api.user.agents.routes.sponsors_after_save", return_value={}),
]
+459
View File
@@ -0,0 +1,459 @@
"""Resource sponsors: an editor's own tool/prompt/source runs inside the owner's agent.
An agent runs as its owner, so a resource the owner can't use used to be
dropped at run time even though an editor attached it. The editor who
attaches it becomes its sponsor (``resource_sponsors``); the resource then
runs while the sponsor can still edit the agent and still use the resource.
Uses real repositories on ``pg_conn``.
"""
from __future__ import annotations
import uuid
from contextlib import ExitStack, contextmanager
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from flask import Flask
from docsgpt.api.user.resource_access import (
active_sponsor,
agent_refs,
ref_principal,
set_settings,
sponsor_key,
sponsors_after_save,
)
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from docsgpt.storage.db.repositories.teams import TeamsRepository
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
OWNER, EDITOR, VIEWER, OTHER = "sp-owner", "sp-editor", "sp-viewer", "sp-other"
_DB_MODULES = (
"docsgpt.api.user.agents.routes",
"docsgpt.api.user.workflows.routes",
"docsgpt.api.user.base",
"docsgpt.agents.tool_executor",
"docsgpt.api.answer.services.stream_processor",
"docsgpt.agents.workflows.workflow_engine",
"docsgpt.storage.db.session",
)
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
import importlib
@contextmanager
def _yield():
yield conn
with ExitStack() as stack:
for mod_name in _DB_MODULES:
mod = importlib.import_module(mod_name)
for attr in ("db_session", "db_readonly"):
if hasattr(mod, attr):
stack.enter_context(patch(f"{mod_name}.{attr}", _yield))
yield
def _call(app, conn, resource_cls, method, path, user, *, json=None, args=()):
kwargs = {"method": method.upper()}
if json is not None:
kwargs["json"] = json
with _patch_db(conn), app.test_request_context(path, **kwargs):
from flask import request
request.decoded_token = {"sub": user}
return getattr(resource_cls(), method.lower())(*args)
def _status(resp) -> int:
return resp[1] if isinstance(resp, tuple) else resp.status_code
def _share_agent(conn, agent_id):
"""Share the agent with a team: EDITOR as editor, VIEWER as viewer."""
team = TeamsRepository(conn).create("T", f"t-{uuid.uuid4().hex[:8]}", OWNER)
tid = str(team["id"])
for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
TeamMembersRepository(conn).add_member(tid, member)
TeamResourceGrantsRepository(conn).grant(
tid, "agent", agent_id, OWNER, OWNER, access_level=level, target_user_id=member
)
return tid
def _agent(conn, **extra):
extra.setdefault("agent_type", "classic")
extra.setdefault("chunks", 6)
row = AgentsRepository(conn).create(
OWNER, "Shared", "published", description="d", key=f"k-{uuid.uuid4().hex}", **extra
)
agent_id = str(row["id"])
team_id = _share_agent(conn, agent_id)
return agent_id, team_id
def _row(conn, agent_id):
return AgentsRepository(conn).get_by_id(agent_id)
def _editor_resources(conn):
tool = str(UserToolsRepository(conn).create(EDITOR, "api_tool")["id"])
prompt = str(PromptsRepository(conn).create(EDITOR, "mine", "Editor prompt")["id"])
source = str(SourcesRepository(conn).create("editor-src", user_id=EDITOR)["id"])
return tool, prompt, source
def _put(app, conn, agent_id, user, body):
from docsgpt.api.user.agents.routes import UpdateAgent
return _call(app, conn, UpdateAgent, "put", f"/api/update_agent/{agent_id}", user,
json=body, args=(agent_id,))
# ---------------------------------------------------------------------------
# sponsors_after_save
# ---------------------------------------------------------------------------
class TestSponsorsAfterSave:
def test_owner_usable_refs_need_no_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, [("tool", tool)])
assert out == {}
def test_editor_resources_are_sponsored_by_editor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, source = _editor_resources(pg_conn)
refs = [("tool", tool), ("prompt", prompt), ("source", source)]
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs)
assert out == {sponsor_key(t, i): EDITOR for t, i in refs}
def test_builtin_tool_and_preset_ids_are_skipped(self, pg_conn):
from docsgpt.agents.default_tools import loaded_builtin_agent_tools, synthesize_builtin_agent_tool
agent_id, _ = _agent(pg_conn)
builtin = next(iter(loaded_builtin_agent_tools()), None)
refs = [("prompt", "default")]
if builtin:
refs.append(("tool", str(synthesize_builtin_agent_tool(builtin)["id"])))
assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs) == {}
def test_owner_save_keeps_editor_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)])
assert out == {sponsor_key("tool", tool): EDITOR}
def test_removed_ref_drops_out(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, []) == {}
def test_another_editor_takes_over_when_sponsor_lost_access(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
# A tool shared with the whole team: both editors may use it in their agents.
tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"])
TeamMembersRepository(pg_conn).add_member(team_id, OTHER)
TeamResourceGrantsRepository(pg_conn).grant(team_id, "tool", tool, OTHER, OTHER)
TeamResourceGrantsRepository(pg_conn).grant(
team_id, "agent", agent_id, OWNER, OWNER, access_level="editor", target_user_id=OTHER
)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OTHER, [("tool", tool)])
assert out == {sponsor_key("tool", tool): OTHER}
def test_dead_sponsor_record_kept_when_nobody_qualifies(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)])
assert out == {sponsor_key("tool", tool): EDITOR}
# ---------------------------------------------------------------------------
# active_sponsor / ref_principal (live checks)
# ---------------------------------------------------------------------------
class TestActiveSponsor:
def _sponsored(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
return agent_id, team_id, tool
def test_live_sponsor_is_principal(self, pg_conn):
agent_id, _, tool = self._sponsored(pg_conn)
agent = _row(pg_conn, agent_id)
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) == EDITOR
assert ref_principal(pg_conn, "agent", agent, "tool", tool) == EDITOR
def test_owner_wins_over_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
AgentsRepository(pg_conn).update_by_id(
agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == OWNER
def test_sponsor_removed_from_agent_stops(self, pg_conn):
agent_id, team_id, tool = self._sponsored(pg_conn)
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_sponsor_demoted_to_viewer_stops(self, pg_conn):
agent_id, team_id, tool = self._sponsored(pg_conn)
TeamResourceGrantsRepository(pg_conn).grant(
team_id, "agent", agent_id, OWNER, OWNER, access_level="viewer", target_user_id=EDITOR
)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_sponsor_losing_the_resource_stops(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"])
TeamResourceGrantsRepository(pg_conn).grant(
team_id, "tool", tool, OTHER, OTHER, target_user_id=EDITOR
)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == EDITOR
set_settings(pg_conn, "tool", tool, {"viewers_can_use_in_agents": False}, OTHER)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_no_record_no_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
# ---------------------------------------------------------------------------
# update_agent / get_agent
# ---------------------------------------------------------------------------
class TestAgentRoutes:
def test_editor_attaching_own_resources_records_sponsor(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, source = _editor_resources(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "prompt_id": prompt, "source": source})
assert _status(resp) == 200
row = _row(pg_conn, agent_id)
assert row["resource_sponsors"] == {
sponsor_key("tool", tool): EDITOR,
sponsor_key("prompt", prompt): EDITOR,
sponsor_key("source", source): EDITOR,
}
assert set(agent_refs(row)) >= {("tool", tool), ("prompt", prompt), ("source", source)}
def test_owner_save_does_not_wipe_sponsors(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
assert _status(_put(app, pg_conn, agent_id, OWNER, {"name": "Renamed", "tools": [tool]})) == 200
assert _row(pg_conn, agent_id)["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
def test_detaching_clears_sponsor(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": []})) == 200
assert _row(pg_conn, agent_id)["resource_sponsors"] == {}
def test_get_agent_lists_sponsors_for_editors_only(self, app, pg_conn):
from docsgpt.api.user.agents.routes import GetAgent
from docsgpt.storage.db.repositories.users import UsersRepository
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
UsersRepository(pg_conn).upsert(EDITOR, email="bob@example.com")
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
path = f"/api/get_agent?id={agent_id}"
owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json()
assert owner_view["resource_sponsors"] == [
{"type": "tool", "id": tool, "user_id": EDITOR, "label": "bob@example.com", "active": True}
]
viewer_view = _call(app, pg_conn, GetAgent, "get", path, VIEWER).get_json()
assert viewer_view["resource_sponsors"] == []
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json()
assert owner_view["resource_sponsors"][0]["active"] is False
# ---------------------------------------------------------------------------
# run time
# ---------------------------------------------------------------------------
class TestRunTime:
def _sponsored_agent(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, prompt, source = _editor_resources(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "prompt_id": prompt, "source": source})
assert _status(resp) == 200
return _row(pg_conn, agent_id), team_id, tool, prompt, source
def test_agent_key_toolset_includes_sponsored_tool(self, app, pg_conn):
from docsgpt.agents.tool_executor import ToolExecutor
agent, team_id, tool, _, _ = self._sponsored_agent(app, pg_conn)
with _patch_db(pg_conn):
tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"])
assert tool in tools
# The row is the tool owner's, so its credentials decrypt as the editor.
assert tools[tool]["user_id"] == EDITOR
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR)
with _patch_db(pg_conn):
tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"])
assert tool not in tools
def test_sponsored_prompt_renders(self, app, pg_conn):
from docsgpt.api.answer.services.stream_processor import authorized_prompt_id
agent, team_id, _, prompt, _ = self._sponsored_agent(app, pg_conn)
with _patch_db(pg_conn):
assert authorized_prompt_id(prompt, OWNER, agent) == prompt
assert authorized_prompt_id(prompt, OWNER) == "default"
TeamResourceGrantsRepository(pg_conn).revoke(
team_id, "agent", str(agent["id"]), target_user_id=EDITOR
)
assert authorized_prompt_id(prompt, OWNER, agent) == "default"
def test_agent_sources_include_sponsored_and_team_shared(self, app, pg_conn):
from docsgpt.api.answer.services.stream_processor import StreamProcessor
agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn)
# A source shared with the owner by a team (not owned): used to be
# dropped by an owner-scoped read.
shared = str(SourcesRepository(pg_conn).create("shared-src", user_id=OTHER)["id"])
if not TeamMembersRepository(pg_conn).is_member(OWNER, team_id):
TeamMembersRepository(pg_conn).add_member(team_id, OWNER)
TeamResourceGrantsRepository(pg_conn).grant(team_id, "source", shared, OTHER, OTHER)
AgentsRepository(pg_conn).update_by_id(str(agent["id"]), {"extra_source_ids": [shared]})
processor = StreamProcessor.__new__(StreamProcessor)
with _patch_db(pg_conn):
data = processor._get_data_from_api_key(agent["key"])
assert [s["id"] for s in data["sources"]] == [source, shared]
def test_search_service_authorizes_sponsored_source(self, app, pg_conn):
from docsgpt.services.search_service import _authorized_source_ids
agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn)
stranger_src = str(SourcesRepository(pg_conn).create("x", user_id="sp-stranger")["id"])
assert _authorized_source_ids(pg_conn, agent, [source, stranger_src]) == [source]
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR)
assert _authorized_source_ids(pg_conn, agent, [source]) == []
# ---------------------------------------------------------------------------
# workflows
# ---------------------------------------------------------------------------
def _wf_body(tool=None, source=None):
agent_cfg = {"agent_type": "classic", "system_prompt": "s", "tools": [tool] if tool else [],
"sources": [source] if source else []}
return {
"name": "WF",
"description": "d",
"nodes": [
{"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
{"id": "a1", "type": "agent", "title": "A", "position": {"x": 50, "y": 0},
"data": {"config": agent_cfg}},
{"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
],
"edges": [
{"id": "e1", "source": "start1", "target": "a1"},
{"id": "e2", "source": "a1", "target": "end1"},
],
}
class TestWorkflows:
def _setup(self, pg_conn):
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
_, team_id = _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
return str(wf["id"]), team_id
def _put(self, app, pg_conn, wid, user, body):
from docsgpt.api.user.workflows.routes import WorkflowDetail
return _call(app, pg_conn, WorkflowDetail, "put", f"/api/workflows/{wid}", user,
json=body, args=(wid,))
def test_editor_node_resources_are_sponsored(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
tool, _, source = _editor_resources(pg_conn)
resp = self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source))
assert _status(resp) == 200, resp.get_json()
row = WorkflowsRepository(pg_conn).get_by_id(wid)
assert row["resource_sponsors"] == {
sponsor_key("tool", tool): EDITOR,
sponsor_key("source", source): EDITOR,
}
# Removing the node's refs clears them.
assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body())) == 200
assert WorkflowsRepository(pg_conn).get_by_id(wid)["resource_sponsors"] == {}
def test_engine_resolves_sponsored_node_refs(self, app, pg_conn):
from docsgpt.agents.workflows.workflow_engine import WorkflowEngine
wid, team_id = self._setup(pg_conn)
tool, _, source = _editor_resources(pg_conn)
assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source))) == 200
engine = WorkflowEngine.__new__(WorkflowEngine)
engine.agent = SimpleNamespace(
workflow_row=WorkflowsRepository(pg_conn).get_by_id(wid),
_resolve_owner_id=lambda: OWNER,
user=OWNER,
decoded_token={"sub": OWNER},
)
with _patch_db(pg_conn):
assert engine._node_tool_principals([tool]) == {tool: EDITOR}
assert engine._authorized_node_sources([source]) == [source]
agent_id = str(pg_conn.exec_driver_sql(
f"SELECT id FROM agents WHERE workflow_id = '{wid}'"
).scalar())
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
assert engine._node_tool_principals([tool]) == {}
assert engine._authorized_node_sources([source]) == []
+9 -9
View File
@@ -31,7 +31,7 @@ def _fake_db_readonly(agent_data):
yield MagicMock()
with patch(
"docsgpt.api.user.team_sharing.can_access", return_value=True
"docsgpt.api.user.resource_access.ref_principal", return_value="owner"
), patch(
"docsgpt.services.search_service.db_readonly", _yield_conn
), patch(
@@ -254,24 +254,24 @@ class TestSourceAuthorization:
return {"id": "agent-1", "user_id": "owner", "extra_source_ids": [], **kw}
def test_readable_sources_pass_through(self, monkeypatch):
import docsgpt.api.user.team_sharing as ts
import docsgpt.api.user.resource_access as ra
monkeypatch.setattr(ts, "can_access", lambda *a, **k: True)
monkeypatch.setattr(ra, "ref_principal", lambda *a, **k: "owner")
agent = self._agent(extra_source_ids=["s1", "s2"])
assert _authorized_source_ids(None, agent, ["s1", "s2"]) == ["s1", "s2"]
def test_foreign_source_is_dropped(self, monkeypatch):
import docsgpt.api.user.team_sharing as ts
import docsgpt.api.user.resource_access as ra
monkeypatch.setattr(ts, "can_access", lambda conn, k, sid, u: sid == "mine")
monkeypatch.setattr(ra, "ref_principal", lambda conn, h, agent, k, sid: "owner" if sid == "mine" else None)
agent = self._agent()
assert _authorized_source_ids(None, agent, ["mine", "theirs"]) == ["mine"]
def test_team_shared_source_is_kept(self, monkeypatch):
"""A grant is legitimate access; only unreadable ids are dropped."""
import docsgpt.api.user.team_sharing as ts
import docsgpt.api.user.resource_access as ra
monkeypatch.setattr(ts, "can_access", lambda *a, **k: True)
monkeypatch.setattr(ra, "ref_principal", lambda *a, **k: "owner")
agent = self._agent(user_id="grantee")
assert _authorized_source_ids(None, agent, ["shared"]) == ["shared"]
@@ -280,12 +280,12 @@ class TestSourceAuthorization:
assert _authorized_source_ids(None, agent, ["s1"]) == []
def test_check_failure_fails_closed(self, monkeypatch):
import docsgpt.api.user.team_sharing as ts
import docsgpt.api.user.resource_access as ra
def _boom(*a, **k):
raise RuntimeError("db down")
monkeypatch.setattr(ts, "can_access", _boom)
monkeypatch.setattr(ra, "ref_principal", _boom)
assert _authorized_source_ids(None, self._agent(), ["s1"]) == []
+1 -1
View File
@@ -149,7 +149,7 @@ def _search_env(agent):
store = MagicMock()
store.search.return_value = [{"text": "hit", "metadata": {"title": "T", "source": "s"}}]
with patch("docsgpt.api.user.team_sharing.can_access", return_value=True), \
with patch("docsgpt.api.user.resource_access.ref_principal", return_value="owner"), \
patch("docsgpt.services.search_service.db_readonly", _conn), \
patch("docsgpt.services.search_service.AgentsRepository", return_value=repo), \
patch(