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