Ask before an editor's resource runs with their access in someone else's agent

Sponsoring a tool, source or prompt the agent's owner can't use now takes
owning it or having edit access to it; use access alone no longer extends it
to the agent's audience. A save that would make the caller a new sponsor is
refused with 409 sponsor_confirmation_required (the resources and the
agent's audience) until it is retried with confirm_sponsor listing them.
When a sponsor loses access, the resource stops instead of passing to
whoever saves next; another editor takes it over only by confirming.
Workflows follow the same rules. sponsor_details now reports each
sponsorship's state, the reason it stopped, and whether the reader can
take it over.
This commit is contained in:
arc53-machine committed 2026-09-29 16:27:54 +01:00
1 parent d4b30f4838
commit 335241e2e6
8 files changed
+923 -95

No files matched your search

+32 -14
View File
@@ -30,12 +30,14 @@ from docsgpt.api.user.resource_access import (
AccessDenied,
agent_refs,
delete_settings,
parse_confirmations,
payload_for,
plan_sponsors,
require,
resolve,
settings_many,
sponsor_details,
sponsors_after_save,
sponsor_refusal,
)
from docsgpt.api.user.team_sharing import (
can_access,
@@ -566,7 +568,7 @@ class GetAgent(Resource):
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)
sponsored = sponsor_details(conn, "agent", agent, viewer=user)
if not agent:
return {"status": "Not found"}, 404
is_owner = ra.access == "owner"
@@ -1429,18 +1431,6 @@ 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``.
@@ -1449,6 +1439,34 @@ class UpdateAgent(Resource):
AccessDenied(403, "Your access doesn't allow changing guardrails or limits")
)
# A resource the owner can't use runs as the editor who
# attached it (its sponsor). Only someone who owns or edits
# it may sponsor it, and only after confirming: the save is
# refused (409) until ``confirm_sponsor`` lists every
# resource it would newly sponsor.
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]
plan = plan_sponsors(
conn,
"agent",
existing_agent,
owner_id,
user,
agent_refs(after_save),
previous_refs=agent_refs(existing_agent),
confirmed=parse_confirmations(data.get("confirm_sponsor")),
)
refusal = sponsor_refusal(
conn, "agent", existing_agent, plan, api_key=bool(update_fields.get("key")) or None
)
if refusal is not None:
body, status = refusal
return make_response(jsonify(body), status)
if plan.sponsors != (existing_agent.get("resource_sponsors") or {}):
update_fields["resource_sponsors"] = plan.sponsors
# Apply update. Owner writes use the dual-key guard; team-editor
# writes go by-id (already authorized) with an optimistic-lock
# check when the client supplies the row's expected updated_at.
+347 -45
View File
@@ -348,15 +348,34 @@ def require(
#
# 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
# the owner can't use, the editor may become 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.
# holder and still may sponsor the resource. A tool still runs with its own
# row's credentials (the tool owner's), whoever the principal is.
#
# Sponsoring extends a resource to everyone who uses the holder, so it takes
# more than being able to use it: the sponsor must own the resource or have
# ``edit`` on it (``can_sponsor_ref``). It is never implied: a save that would
# make the caller a new sponsor needs their explicit confirmation
# (``plan_sponsors``), and when a sponsor loses access the resource stops
# rather than passing to whoever saves next.
# 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"}
# Action a user needs on a resource to sponsor it (owners have every action).
SPONSOR_ACTION = "edit"
# Why a recorded sponsorship doesn't run (``sponsor_details`` ``reason``).
REASON_CANNOT_EDIT_HOLDER = "sponsor_cannot_edit_agent"
REASON_CANNOT_EDIT_RESOURCE = "sponsor_cannot_edit_resource"
# Error codes of a save the sponsor rules refuse.
CODE_CONFIRMATION_REQUIRED = "sponsor_confirmation_required"
CODE_NOT_ALLOWED = "sponsor_not_allowed"
CODE_UNEXPECTED_CONFIRMATION = "sponsor_confirmation_unexpected"
def sponsor_key(resource_type: str, resource_id: str) -> str:
"""The ``resource_sponsors`` key for one referenced resource."""
@@ -382,6 +401,28 @@ def can_use_ref(conn: Connection, resource_type: str, resource_id: str, user_id:
return ra is not None and ra.can(REF_USE_ACTION[resource_type])
def can_sponsor_ref(conn: Connection, resource_type: str, resource_id: str, user_id: Optional[str]) -> bool:
"""Whether ``user_id`` may extend ``resource_id`` to someone else's agent.
Using a resource is not enough to hand it to another agent's audience:
the sponsor must own it or reach it with ``edit`` through a team grant
(any team).
Args:
conn: Open database connection.
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
user_id: The would-be sponsor.
Returns:
True when the user owns the resource or may edit it.
"""
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(SPONSOR_ACTION) 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``.
@@ -392,11 +433,7 @@ def _holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_i
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:
for agent_id in _workflow_agent_ids(conn, holder):
ra = resolve(conn, "agent", str(agent_id), user_id)
if ra is not None and ra.can("edit"):
return True
@@ -404,6 +441,51 @@ def _holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_i
raise ValueError(f"Unknown sponsor holder type: {holder_type}")
def _workflow_agent_ids(conn: Connection, workflow: dict) -> list:
"""The ids of the workflow owner's agents that run ``workflow``."""
return conn.execute(
text("SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"),
{"wid": str(workflow["id"]), "owner": workflow.get("user_id")},
).scalars().all()
def sponsor_state(
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
) -> tuple[Optional[str], Optional[str]]:
"""The recorded sponsor of one reference and why it doesn't run, if it doesn't.
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:
``(sponsor, reason)``: ``sponsor`` is the recorded user or None;
``reason`` is None while the sponsorship runs, else
:data:`REASON_CANNOT_EDIT_HOLDER` or :data:`REASON_CANNOT_EDIT_RESOURCE`.
With no usable record both are None.
"""
if not holder or not resource_id:
return None, None
recorded = holder.get("resource_sponsors") or {}
sponsor = recorded.get(sponsor_key(resource_type, str(resource_id))) or recorded.get(
sponsor_key(resource_type, str(resource_id).lower())
)
if not sponsor or sponsor == holder.get("user_id"):
return None, None
try:
if not _holder_editable_by(conn, holder_type, holder, sponsor):
return sponsor, REASON_CANNOT_EDIT_HOLDER
if not can_sponsor_ref(conn, resource_type, str(resource_id), sponsor):
return sponsor, REASON_CANNOT_EDIT_RESOURCE
except Exception:
logger.exception("Sponsor check failed for %s %s", resource_type, resource_id)
return sponsor, REASON_CANNOT_EDIT_RESOURCE
return sponsor, None
def active_sponsor(
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
) -> Optional[str]:
@@ -418,20 +500,10 @@ def active_sponsor(
Returns:
The sponsor's id when one is recorded, still edits the holder and
can still use the resource; else None.
still may sponsor the resource (owns or edits it); 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
sponsor, reason = sponsor_state(conn, holder_type, holder, resource_type, resource_id)
return sponsor if sponsor and reason is None else None
def ref_principal(
@@ -471,21 +543,83 @@ def agent_refs(agent: dict) -> list[tuple[str, str]]:
return refs
def sponsors_after_save(
def parse_confirmations(raw) -> set[str]:
"""``confirm_sponsor`` from a request as canonical ``"<type>:<id>"`` keys.
Accepts a list, a JSON-encoded list (form posts) or a comma-separated
string. Unknown shapes and malformed entries are dropped, so they can
never confirm anything.
Args:
raw: The request value, or None.
Returns:
set: The confirmed keys, ids lowercased like stored refs.
"""
if raw is None or raw == "":
return set()
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (ValueError, TypeError):
raw = raw.split(",")
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, (list, tuple)):
return set()
out: set[str] = set()
for item in raw:
resource_type, _, resource_id = str(item).strip().partition(":")
if resource_type in REF_USE_ACTION and resource_id:
out.add(sponsor_key(resource_type, canonical_uuid(resource_id.strip())))
return out
@dataclass
class SponsorPlan:
"""What a save does to a holder's sponsors, and what stops it.
Attributes:
sponsors: The ``resource_sponsors`` map to store.
needs_confirmation: ``(type, id)`` refs the save would newly have the
caller sponsor without their confirmation; the save must be
refused with :data:`CODE_CONFIRMATION_REQUIRED`.
not_allowed: Newly attached ``(type, id)`` refs the owner can't use
and the caller may not sponsor; the save must be refused.
unexpected: Confirmed keys this save has no sponsorship for.
"""
sponsors: dict = field(default_factory=dict)
needs_confirmation: list = field(default_factory=list)
not_allowed: list = field(default_factory=list)
unexpected: list = field(default_factory=list)
def plan_sponsors(
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``.
previous_refs: Iterable[tuple[str, str]] = (),
confirmed: Iterable[str] = (),
) -> SponsorPlan:
"""Work out the sponsors 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.
Per referenced resource the owner can't use:
* a recorded sponsor who still qualifies is kept;
* the caller becomes the sponsor only when they may sponsor it
(:func:`can_sponsor_ref`) and listed its key in ``confirmed``;
* a newly attached one the caller may sponsor but didn't confirm goes to
``needs_confirmation``; one they may not sponsor goes to ``not_allowed``;
* one already attached keeps its old record even when that sponsor lost
access: it stays stopped until someone confirms taking it over.
Resources the owner can use, presets and builtin tools need no sponsor.
Removed refs drop out. A confirmed key that names none of the refs the
caller could sponsor lands in ``unexpected``.
Args:
conn: Open database connection.
@@ -494,39 +628,188 @@ def sponsors_after_save(
owner_id: The holder's owner.
caller: The user saving.
refs: Every ``(type, id)`` the holder references after the save.
previous_refs: Every ``(type, id)`` it referenced before the save.
confirmed: ``"<type>:<id>"`` keys the caller agreed to sponsor.
Returns:
dict: ``"<type>:<id>" -> user_id``.
SponsorPlan: The map to store and anything that refuses the save.
"""
previous = (holder or {}).get("resource_sponsors") or {}
out: dict[str, str] = {}
before = {sponsor_key(t, str(i).lower()) for t, i in previous_refs}
confirmed = set(confirmed)
plan = SponsorPlan()
eligible: set[str] = set()
seen: set[str] = set()
for resource_type, resource_id in refs:
resource_id = str(resource_id).lower()
key = sponsor_key(resource_type, resource_id)
if key in out or not _sponsorable(resource_type, resource_id):
if key in seen or not _sponsorable(resource_type, resource_id):
continue
seen.add(key)
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
is_new = key not in before
caller_may = caller != owner_id and can_sponsor_ref(conn, resource_type, resource_id, caller)
if caller_may:
eligible.add(key)
live = active_sponsor(conn, holder_type, holder, resource_type, resource_id)
if live:
plan.sponsors[key] = live
elif caller_may and key in confirmed:
plan.sponsors[key] = caller
elif is_new and caller != owner_id:
(plan.needs_confirmation if caller_may else plan.not_allowed).append((resource_type, resource_id))
elif previous.get(key):
out[key] = previous[key]
plan.sponsors[key] = previous[key]
plan.unexpected = sorted(confirmed - eligible)
return plan
def ref_names(conn: Connection, refs: Iterable[tuple[str, str]]) -> dict[str, str]:
"""Display names of referenced resources, looked up by id (owner-agnostic).
Args:
conn: Open database connection.
refs: ``(type, id)`` pairs.
Returns:
dict: ``"<type>:<id>" -> name`` for the ones found.
"""
queries = {
"source": "SELECT id, name FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
"prompt": "SELECT id, name FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
"tool": (
"SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name) "
"FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))"
),
}
by_type: dict[str, list[str]] = {}
for resource_type, resource_id in refs:
if resource_type in queries and looks_like_uuid(str(resource_id)):
by_type.setdefault(resource_type, []).append(str(resource_id))
out: dict[str, str] = {}
for resource_type, ids in by_type.items():
for rid, name in conn.execute(text(queries[resource_type]), {"ids": ids}).fetchall():
if name:
out[sponsor_key(resource_type, str(rid))] = name
return out
def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> list[dict]:
def holder_audience(conn: Connection, holder_type: str, holder: dict, *, api_key: Optional[bool] = None) -> dict:
"""Who reaches a holder's resources: its teams and outside entry points.
For a workflow, the union over the owner's agents that run it.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
api_key: Override for an agent whose key this save creates.
Returns:
dict: ``teams`` (names, sorted), and booleans ``api_key`` (API and
widget), ``public_link`` and ``webhook``.
"""
if holder_type == "agent":
agents = [holder]
else:
ids = [str(a) for a in _workflow_agent_ids(conn, holder)]
agents = [a for a in (AgentsRepository(conn).get_by_id(i) for i in ids) if a]
grants_repo = TeamResourceGrantsRepository(conn)
teams: set[str] = set()
for agent in agents:
teams.update(g.get("team_name") for g in grants_repo.list_for_resource("agent", str(agent["id"])))
has_key = any(a.get("key") for a in agents)
return {
"teams": sorted(t for t in teams if t),
"api_key": bool(has_key if api_key is None else api_key or has_key),
"public_link": any(a.get("shared") and a.get("shared_token") for a in agents),
"webhook": any(a.get("incoming_webhook_token") for a in agents),
}
def sponsor_refusal(
conn: Connection, holder_type: str, holder: dict, plan: SponsorPlan, *, api_key: Optional[bool] = None
) -> Optional[tuple[dict, int]]:
"""The error body and status for a save ``plan`` refuses, or None.
In order: 403 :data:`CODE_NOT_ALLOWED` (a new resource the caller may not
sponsor), 400 :data:`CODE_UNEXPECTED_CONFIRMATION`, then 409
:data:`CODE_CONFIRMATION_REQUIRED` listing what the caller would sponsor
and the holder's audience, so the client can ask and retry with
``confirm_sponsor``.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row before the save.
plan: The result of :func:`plan_sponsors`.
api_key: Passed to :func:`holder_audience`.
Returns:
``(body, status)`` or None when the save may go ahead.
"""
def _resources(pairs: list) -> list[dict]:
names = ref_names(conn, pairs)
return [
{
"key": sponsor_key(t, i),
"type": t,
"id": i,
"name": names.get(sponsor_key(t, i)),
}
for t, i in pairs
]
if plan.not_allowed:
return {
"success": False,
"code": CODE_NOT_ALLOWED,
"message": (
"You can't add a resource the owner can't use unless you own it or can edit it."
),
"resources": _resources(plan.not_allowed),
}, 403
if plan.unexpected:
return {
"success": False,
"code": CODE_UNEXPECTED_CONFIRMATION,
"message": "confirm_sponsor lists resources this save doesn't ask you to sponsor.",
"unexpected": plan.unexpected,
}, 400
if plan.needs_confirmation:
return {
"success": False,
"code": CODE_CONFIRMATION_REQUIRED,
"message": (
"These resources would run with your access for everyone who uses this agent. "
"Confirm to add them."
),
"resources": _resources(plan.needs_confirmation),
"audience": holder_audience(conn, holder_type, holder, api_key=api_key),
}, 409
return None
def sponsor_details(
conn: Connection, holder_type: str, holder: dict, viewer: Optional[str] = None
) -> 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.
viewer: The user reading the page; sets ``can_confirm``.
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).
list: Per sponsored resource ``{key, type, id, name, user_id, label,
state, reason, active, can_confirm}``. ``label`` is the sponsor's
email when on file; ``state`` is ``active`` or ``inactive``, and
``reason`` (None while active) is :data:`REASON_CANNOT_EDIT_HOLDER`
or :data:`REASON_CANNOT_EDIT_RESOURCE`; ``active`` mirrors ``state``.
``can_confirm`` says whether ``viewer`` may take an inactive one
over by confirming it on their next save.
"""
sponsors = holder.get("resource_sponsors") or {}
if not sponsors:
@@ -541,18 +824,37 @@ def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> list[di
{"ids": user_ids},
).fetchall()
) if user_ids else {}
out = []
entries = []
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
if resource_type in REF_USE_ACTION and resource_id:
entries.append((key, resource_type, resource_id, user_id))
names = ref_names(conn, [(t, i) for _, t, i, _ in entries])
viewer_edits = bool(
viewer
and viewer != holder.get("user_id")
and _holder_editable_by(conn, holder_type, holder, viewer)
)
out = []
for key, resource_type, resource_id, user_id in entries:
_, reason = sponsor_state(conn, holder_type, holder, resource_type, resource_id)
active = reason is None
out.append(
{
"key": key,
"type": resource_type,
"id": resource_id,
"name": names.get(key),
"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,
"state": "active" if active else "inactive",
"reason": reason,
"active": active,
"can_confirm": bool(
not active
and viewer_edits
and can_sponsor_ref(conn, resource_type, resource_id, viewer)
),
}
)
return out
+30 -15
View File
@@ -13,9 +13,11 @@ from docsgpt.agents.workflows.cel_evaluator import (
from docsgpt.api.user.resource_access import (
AccessDenied,
can_use_ref,
parse_confirmations,
plan_sponsors,
resolve,
sponsor_details,
sponsors_after_save,
sponsor_refusal,
)
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
@@ -631,7 +633,7 @@ class WorkflowDetail(Resource):
edges = WorkflowEdgesRepository(conn).find_by_version(
pg_workflow_id, graph_version,
)
sponsored = sponsor_details(conn, "workflow", workflow)
sponsored = sponsor_details(conn, "workflow", workflow, viewer=user_id)
except Exception as err:
return _workflow_error_response("Failed to fetch workflow", err)
@@ -674,16 +676,34 @@ class WorkflowDetail(Resource):
nodes_data = normalize_agent_node_json_schemas(nodes_data)
pg_workflow_id = str(workflow["id"])
current_graph_version = get_workflow_graph_version(workflow)
previous_nodes = [
serialize_node(n)
for n in WorkflowNodesRepository(conn).find_by_version(
pg_workflow_id, current_graph_version,
)
]
if acting != user_id:
previous_nodes = [
serialize_node(n)
for n in WorkflowNodesRepository(conn).find_by_version(
pg_workflow_id, current_graph_version,
)
]
denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
if denied is not None:
return _denied(denied)
# A node tool/source the owner can't use runs as the editor
# who attached it (its sponsor): only someone who owns or
# edits it, and only once ``confirm_sponsor`` lists it.
plan = plan_sponsors(
conn,
"workflow",
workflow,
acting,
user_id,
_node_refs(nodes_data),
previous_refs=_node_refs(previous_nodes),
confirmed=parse_confirmations(data.get("confirm_sponsor")),
)
refusal = sponsor_refusal(conn, "workflow", workflow, plan)
if refusal is not None:
body, status = refusal
body.setdefault("error", body["message"])
return make_response(jsonify(body), status)
next_graph_version = current_graph_version + 1
_write_graph(
@@ -695,13 +715,8 @@ class WorkflowDetail(Resource):
"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
if plan.sponsors != (workflow.get("resource_sponsors") or {}):
workflow_fields["resource_sponsors"] = plan.sponsors
repo.update(pg_workflow_id, acting, workflow_fields)
WorkflowNodesRepository(conn).delete_other_versions(
pg_workflow_id, next_graph_version,
+2 -1
View File
@@ -65,7 +65,8 @@ def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -
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.
attached it (its sponsor) can edit the agent and still owns or edits
the source.
Returns:
list: The subset the agent's owner (or a live sponsor) may read.
+3 -1
View File
@@ -188,6 +188,8 @@ class TestGetAgentResolvesNames:
def _update_patches(sub, repo, team_access, can_access_mock):
from docsgpt.api.user.resource_access import SponsorPlan
return [
patch("docsgpt.app.handle_auth", return_value={"sub": sub}),
patch("docsgpt.app.resolve_roles", return_value=["user"]),
@@ -197,7 +199,7 @@ def _update_patches(sub, repo, team_access, can_access_mock):
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={}),
patch("docsgpt.api.user.agents.routes.plan_sponsors", return_value=SponsorPlan()),
]
+6 -1
View File
@@ -283,7 +283,12 @@ class TestUpdateAgent:
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [foreign_tool]})
assert _status(denied) == 403
assert _status(self._put(app, pg_conn, agent_id, OWNER, {"tools": [owner_tool]})) == 200
ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
# The owner can't use the shared tool, so the editor sponsors it,
# which takes their confirmation.
unconfirmed = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
assert _status(unconfirmed) == 409
ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool],
"confirm_sponsor": [f"tool:{shared_tool}"]})
assert _status(ok) == 200
set_settings(pg_conn, "tool", shared_tool, {"viewers_can_use_in_agents": False}, STRANGER)
+70 -18
View File
@@ -20,10 +20,10 @@ from flask import Flask
from docsgpt.api.user.resource_access import (
active_sponsor,
agent_refs,
plan_sponsors,
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
@@ -123,6 +123,20 @@ def _editor_resources(conn):
return tool, prompt, source
def _confirm(*refs):
"""``confirm_sponsor`` for ``(type, id)`` pairs."""
return [sponsor_key(t, i) for t, i in refs]
def sponsors_after_save(conn, holder_type, holder, owner_id, caller, refs, previous_refs=(), confirm_all=True):
"""The stored map after a save that confirms every sponsorship it can."""
refs = list(refs)
confirmed = [sponsor_key(t, i) for t, i in refs] if confirm_all else []
plan = plan_sponsors(conn, holder_type, holder, owner_id, caller, refs,
previous_refs=previous_refs, confirmed=confirmed)
return plan.sponsors
def _put(app, conn, agent_id, user, body):
from docsgpt.api.user.agents.routes import UpdateAgent
@@ -165,9 +179,17 @@ class TestSponsorsAfterSave:
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)])
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)],
previous_refs=[("tool", tool)], confirm_all=False)
assert out == {sponsor_key("tool", tool): EDITOR}
def test_unconfirmed_new_sponsorship_is_not_recorded(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
plan = plan_sponsors(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, [("tool", tool)])
assert plan.sponsors == {}
assert plan.needs_confirmation == [("tool", tool)]
def test_removed_ref_drops_out(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
@@ -176,12 +198,12 @@ class TestSponsorsAfterSave:
)
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):
def test_another_editor_takes_over_only_by_confirming(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.
# A tool the whole team may edit: both editors may sponsor it.
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, "tool", tool, OTHER, OTHER, access_level="editor")
TeamResourceGrantsRepository(pg_conn).grant(
team_id, "agent", agent_id, OWNER, OWNER, access_level="editor", target_user_id=OTHER
)
@@ -190,7 +212,12 @@ class TestSponsorsAfterSave:
)
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)])
refs = [("tool", tool)]
agent = _row(pg_conn, agent_id)
kept = sponsors_after_save(pg_conn, "agent", agent, OWNER, OTHER, refs, previous_refs=refs,
confirm_all=False)
assert kept == {sponsor_key("tool", tool): EDITOR}
out = sponsors_after_save(pg_conn, "agent", agent, OWNER, OTHER, refs, previous_refs=refs)
assert out == {sponsor_key("tool", tool): OTHER}
def test_dead_sponsor_record_kept_when_nobody_qualifies(self, pg_conn):
@@ -200,7 +227,8 @@ class TestSponsorsAfterSave:
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)])
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)],
previous_refs=[("tool", tool)])
assert out == {sponsor_key("tool", tool): EDITOR}
@@ -248,12 +276,25 @@ class TestActiveSponsor:
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
team_id, "tool", tool, OTHER, OTHER, access_level="editor", 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
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "tool", tool, target_user_id=EDITOR)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_use_only_sponsor_never_runs(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) is None
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
@@ -273,7 +314,8 @@ class TestAgentRoutes:
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})
{"tools": [tool], "prompt_id": prompt, "source": source,
"confirm_sponsor": _confirm(("tool", tool), ("prompt", prompt), ("source", source))})
assert _status(resp) == 200
row = _row(pg_conn, agent_id)
assert row["resource_sponsors"] == {
@@ -286,14 +328,16 @@ class TestAgentRoutes:
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, EDITOR,
{"tools": [tool], "confirm_sponsor": _confirm(("tool", 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": [tool], "confirm_sponsor": _confirm(("tool", tool))})) == 200
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": []})) == 200
assert _row(pg_conn, agent_id)["resource_sponsors"] == {}
@@ -304,12 +348,15 @@ class TestAgentRoutes:
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
assert _status(_put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "confirm_sponsor": _confirm(("tool", 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}
{"key": f"tool:{tool}", "type": "tool", "id": tool, "name": "api_tool", "user_id": EDITOR,
"label": "bob@example.com", "state": "active", "reason": None, "active": True,
"can_confirm": False}
]
viewer_view = _call(app, pg_conn, GetAgent, "get", path, VIEWER).get_json()
assert viewer_view["resource_sponsors"] == []
@@ -329,7 +376,8 @@ class TestRunTime:
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})
{"tools": [tool], "prompt_id": prompt, "source": source,
"confirm_sponsor": _confirm(("tool", tool), ("prompt", prompt), ("source", source))})
assert _status(resp) == 200
return _row(pg_conn, agent_id), team_id, tool, prompt, source
@@ -509,12 +557,13 @@ class TestToolPrefetch:
# ---------------------------------------------------------------------------
def _wf_body(tool=None, source=None, tools=None):
def _wf_body(tool=None, source=None, tools=None, confirm=()):
if tools is None:
tools = [tool] if tool else []
agent_cfg = {"agent_type": "classic", "system_prompt": "s", "tools": tools,
"sources": [source] if source else []}
return {
"confirm_sponsor": list(confirm),
"name": "WF",
"description": "d",
"nodes": [
@@ -545,7 +594,8 @@ class TestWorkflows:
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))
resp = self._put(app, pg_conn, wid, EDITOR,
_wf_body(tool, source, confirm=_confirm(("tool", tool), ("source", source))))
assert _status(resp) == 200, resp.get_json()
row = WorkflowsRepository(pg_conn).get_by_id(wid)
assert row["resource_sponsors"] == {
@@ -561,7 +611,8 @@ class TestWorkflows:
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
confirm = _confirm(("tool", tool), ("source", source))
assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source, confirm=confirm))) == 200
engine = WorkflowEngine.__new__(WorkflowEngine)
engine.agent = SimpleNamespace(
@@ -626,7 +677,8 @@ class TestWorkflows:
wid, _ = self._setup(pg_conn)
owner_tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
editor_tool, _, _ = _editor_resources(pg_conn)
assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body(editor_tool))) == 200
assert _status(self._put(app, pg_conn, wid, EDITOR,
_wf_body(editor_tool, confirm=_confirm(("tool", editor_tool))))) == 200
tools = [owner_tool, editor_tool]
assert _status(self._put(app, pg_conn, wid, OWNER, _wf_body(tools=tools))) == 200
+433
View File
@@ -0,0 +1,433 @@
"""Who may sponsor a resource, and only with their explicit consent.
A team editor who attaches a resource the agent's owner can't use makes it
run with the editor's access for everyone who uses the agent. That takes
owning the resource or having edit access to it (plain use is not enough),
an explicit ``confirm_sponsor`` on the save, and never happens silently when
a previous sponsor loses access. Uses real repositories on ``pg_conn``.
"""
from __future__ import annotations
import uuid
import pytest
from flask import Flask
from docsgpt.api.user.resource_access import (
CODE_CONFIRMATION_REQUIRED,
CODE_NOT_ALLOWED,
CODE_UNEXPECTED_CONFIRMATION,
REASON_CANNOT_EDIT_HOLDER,
REASON_CANNOT_EDIT_RESOURCE,
active_sponsor,
parse_confirmations,
plan_sponsors,
sponsor_details,
sponsor_key,
)
from docsgpt.storage.db.repositories.agents import AgentsRepository
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
from tests.api.user.test_resource_sponsors import (
EDITOR,
OTHER,
OWNER,
_agent,
_call,
_editor_resources,
_put,
_row,
_status,
_wf_body,
)
STRANGER = "sp-stranger"
@pytest.fixture
def app():
return Flask(__name__)
def _body(resp) -> dict:
return resp[0] if isinstance(resp, tuple) else resp.get_json()
def _other_team(conn, *members):
"""A second team, owned by OTHER, that the agent is not shared with."""
team = TeamsRepository(conn).create("Elsewhere", f"e-{uuid.uuid4().hex[:8]}", OTHER)
tid = str(team["id"])
for member in (OTHER, *members):
if not TeamMembersRepository(conn).is_member(member, tid):
TeamMembersRepository(conn).add_member(tid, member)
return tid
def _others_tool(conn, team_id, user, level):
"""OTHER's tool, shared with ``user`` in ``team_id`` at ``level``."""
tool = str(UserToolsRepository(conn).create(OTHER, "api_tool")["id"])
TeamResourceGrantsRepository(conn).grant(team_id, "tool", tool, OTHER, OTHER, access_level=level,
target_user_id=user)
return tool
def _others_source(conn, team_id, user, level):
source = str(SourcesRepository(conn).create("others-src", user_id=OTHER)["id"])
TeamResourceGrantsRepository(conn).grant(team_id, "source", source, OTHER, OTHER, access_level=level,
target_user_id=user)
return source
def _add_agent_editor(conn, team_id, agent_id, user):
if not TeamMembersRepository(conn).is_member(user, team_id):
TeamMembersRepository(conn).add_member(team_id, user)
TeamResourceGrantsRepository(conn).grant(
team_id, "agent", agent_id, OWNER, OWNER, access_level="editor", target_user_id=user
)
# ---------------------------------------------------------------------------
# A: who may sponsor
# ---------------------------------------------------------------------------
class TestWhoMaySponsor:
def test_use_only_share_cannot_sponsor(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
team = _other_team(pg_conn, EDITOR)
source = _others_source(pg_conn, team, EDITOR, "viewer")
tool = _others_tool(pg_conn, team, EDITOR, "viewer")
resp = _put(app, pg_conn, agent_id, EDITOR, {"sources": [source], "tools": [tool],
"confirm_sponsor": [f"source:{source}", f"tool:{tool}"]})
assert _status(resp) == 403
body = _body(resp)
assert body["code"] == CODE_NOT_ALLOWED
assert {r["key"] for r in body["resources"]} == {f"source:{source}", f"tool:{tool}"}
row = _row(pg_conn, agent_id)
assert not row["tools"] and not row["extra_source_ids"] and not row["source_id"]
assert row["resource_sponsors"] == {}
def test_edit_share_from_another_team_can_sponsor(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
team = _other_team(pg_conn, EDITOR)
tool = _others_tool(pg_conn, team, EDITOR, "editor")
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})
assert _status(resp) == 200, _body(resp)
agent = _row(pg_conn, agent_id)
assert agent["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) == EDITOR
def test_owner_usable_resource_needs_no_sponsor_or_consent(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
TeamResourceGrantsRepository(pg_conn).grant(team_id, "tool", tool, OWNER, OWNER, target_user_id=EDITOR)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})
assert _status(resp) == 200, _body(resp)
assert _row(pg_conn, agent_id)["resource_sponsors"] == {}
def test_existing_use_only_sponsorship_stops_running(self, pg_conn):
agent_id, _ = _agent(pg_conn)
team = _other_team(pg_conn, EDITOR)
tool = _others_tool(pg_conn, team, EDITOR, "viewer")
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
agent = _row(pg_conn, agent_id)
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) is None
[detail] = sponsor_details(pg_conn, "agent", agent)
assert detail["state"] == "inactive"
assert detail["reason"] == REASON_CANNOT_EDIT_RESOURCE
# ---------------------------------------------------------------------------
# B: explicit consent
# ---------------------------------------------------------------------------
class TestConsent:
def test_new_sponsorship_needs_confirmation(self, app, pg_conn):
from docsgpt.storage.db.repositories.users import UsersRepository
agent_id, _ = _agent(pg_conn)
UsersRepository(pg_conn).upsert(EDITOR)
AgentsRepository(pg_conn).update_by_id(agent_id, {"shared": True, "shared_token": uuid.uuid4().hex})
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) == 409
body = _body(resp)
assert body["code"] == CODE_CONFIRMATION_REQUIRED
by_key = {r["key"]: r for r in body["resources"]}
assert set(by_key) == {f"tool:{tool}", f"prompt:{prompt}", f"source:{source}"}
assert by_key[f"source:{source}"] == {"key": f"source:{source}", "type": "source", "id": source,
"name": "editor-src"}
assert by_key[f"prompt:{prompt}"]["name"] == "mine"
assert body["audience"] == {"teams": ["T"], "api_key": True, "public_link": True, "webhook": False}
row = _row(pg_conn, agent_id)
assert not row["tools"] and row["resource_sponsors"] == {}
def test_exact_confirmation_saves(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, _ = _editor_resources(pg_conn)
confirm = [f"tool:{tool}", f"prompt:{prompt.upper()}"]
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool], "prompt_id": prompt, "confirm_sponsor": confirm})
assert _status(resp) == 200, _body(resp)
assert _row(pg_conn, agent_id)["resource_sponsors"] == {
sponsor_key("tool", tool): EDITOR,
sponsor_key("prompt", prompt): EDITOR,
}
def test_form_encoded_confirmation(self, app, pg_conn):
from docsgpt.api.user.agents.routes import UpdateAgent
from tests.api.user.test_resource_sponsors import _patch_db
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
data = {"tools": f'["{tool}"]', "confirm_sponsor": f'["tool:{tool}"]'}
with _patch_db(pg_conn), app.test_request_context(f"/api/update_agent/{agent_id}", method="PUT",
data=data):
from flask import request
request.decoded_token = {"sub": EDITOR}
resp = UpdateAgent().put(agent_id)
assert _status(resp) == 200, _body(resp)
assert _row(pg_conn, agent_id)["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
def test_partial_confirmation_is_refused(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, _ = _editor_resources(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "prompt_id": prompt, "confirm_sponsor": [f"tool:{tool}"]})
assert _status(resp) == 409
assert [r["key"] for r in _body(resp)["resources"]] == [f"prompt:{prompt}"]
assert not _row(pg_conn, agent_id)["tools"]
def test_unrelated_confirmation_is_rejected(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
stray = str(uuid.uuid4())
resp = _put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "confirm_sponsor": [f"tool:{tool}", f"source:{stray}"]})
assert _status(resp) == 400
body = _body(resp)
assert body["code"] == CODE_UNEXPECTED_CONFIRMATION
assert body["unexpected"] == [f"source:{stray}"]
assert _row(pg_conn, agent_id)["resource_sponsors"] == {}
def test_confirming_an_owner_usable_resource_is_rejected(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
TeamResourceGrantsRepository(pg_conn).grant(team_id, "tool", tool, OWNER, OWNER, target_user_id=EDITOR)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})
assert _status(resp) == 400
def test_unchanged_save_needs_no_confirmation(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], "confirm_sponsor": [f"tool:{tool}"]})) == 200
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"name": "Again", "tools": [tool]})) == 200
assert _status(_put(app, pg_conn, agent_id, OWNER, {"name": "Owner", "tools": [tool]})) == 200
assert _row(pg_conn, agent_id)["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
def test_parse_confirmations(self):
rid = str(uuid.uuid4())
assert parse_confirmations(None) == set()
assert parse_confirmations([f"tool:{rid.upper()}"]) == {f"tool:{rid}"}
assert parse_confirmations(f'["source:{rid}"]') == {f"source:{rid}"}
assert parse_confirmations(f"prompt:{rid}, agent:{rid}, nonsense") == {f"prompt:{rid}"}
assert parse_confirmations({"tool": rid}) == set()
# ---------------------------------------------------------------------------
# C: no silent handover
# ---------------------------------------------------------------------------
class TestNoSilentHandover:
def _sponsored_then_lost(self, app, pg_conn):
"""EDITOR sponsors OTHER's tool (edit share), then loses edit on the agent.
OTHER edits the agent too and may sponsor the tool themselves.
"""
agent_id, team_id = _agent(pg_conn)
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, access_level="editor")
_add_agent_editor(pg_conn, team_id, agent_id, OTHER)
assert _status(_put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})) == 200
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
return agent_id, team_id, tool
def test_lost_sponsor_is_inactive(self, app, pg_conn):
agent_id, _, tool = self._sponsored_then_lost(app, pg_conn)
agent = _row(pg_conn, agent_id)
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) is None
[detail] = sponsor_details(pg_conn, "agent", agent, viewer=OTHER)
assert detail["state"] == "inactive"
assert detail["reason"] == REASON_CANNOT_EDIT_HOLDER
assert detail["user_id"] == EDITOR
assert detail["can_confirm"] is True
assert sponsor_details(pg_conn, "agent", agent, viewer=OWNER)[0]["can_confirm"] is False
def test_unrelated_save_does_not_transfer(self, app, pg_conn):
agent_id, _, tool = self._sponsored_then_lost(app, pg_conn)
resp = _put(app, pg_conn, agent_id, OTHER, {"name": "Renamed", "tools": [tool]})
assert _status(resp) == 200, _body(resp)
agent = _row(pg_conn, agent_id)
assert [str(t) for t in agent["tools"]] == [tool]
assert agent["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) is None
def test_explicit_confirmation_transfers(self, app, pg_conn):
agent_id, _, tool = self._sponsored_then_lost(app, pg_conn)
resp = _put(app, pg_conn, agent_id, OTHER, {"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})
assert _status(resp) == 200, _body(resp)
agent = _row(pg_conn, agent_id)
assert agent["resource_sponsors"] == {sponsor_key("tool", tool): OTHER}
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) == OTHER
def test_takeover_needs_edit_on_the_resource(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool = _others_tool(pg_conn, _other_team(pg_conn, EDITOR), EDITOR, "editor")
assert _status(_put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})) == 200
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
_add_agent_editor(pg_conn, team_id, agent_id, STRANGER)
# STRANGER edits the agent but has no access to the tool at all.
resp = _put(app, pg_conn, agent_id, STRANGER, {"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})
assert _status(resp) == 400
assert _row(pg_conn, agent_id)["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
def test_sponsor_demoted_on_resource_stops(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
team = _other_team(pg_conn, EDITOR)
tool = _others_tool(pg_conn, team, EDITOR, "editor")
assert _status(_put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})) == 200
TeamResourceGrantsRepository(pg_conn).grant(team, "tool", tool, OTHER, OTHER, access_level="viewer",
target_user_id=EDITOR)
agent = _row(pg_conn, agent_id)
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) is None
assert sponsor_details(pg_conn, "agent", agent)[0]["reason"] == REASON_CANNOT_EDIT_RESOURCE
# The same editor saving again doesn't quietly keep it running either.
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"name": "x", "tools": [tool]})) == 200
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_plan_keeps_stopped_record_without_confirmation(self, app, pg_conn):
agent_id, _, tool = self._sponsored_then_lost(app, pg_conn)
agent = _row(pg_conn, agent_id)
refs = [("tool", tool)]
plan = plan_sponsors(pg_conn, "agent", agent, OWNER, OTHER, refs, previous_refs=refs)
assert plan.sponsors == {sponsor_key("tool", tool): EDITOR}
assert not plan.needs_confirmation and not plan.not_allowed and not plan.unexpected
# ---------------------------------------------------------------------------
# get_agent
# ---------------------------------------------------------------------------
class TestGetAgentDetails:
def test_sponsor_details_carry_state(self, app, pg_conn):
from docsgpt.api.user.agents.routes import GetAgent
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert _status(_put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "confirm_sponsor": [f"tool:{tool}"]})) == 200
data = _call(app, pg_conn, GetAgent, "get", f"/api/get_agent?id={agent_id}", EDITOR).get_json()
assert data["resource_sponsors"] == [{
"key": f"tool:{tool}", "type": "tool", "id": tool, "name": "api_tool", "user_id": EDITOR,
"label": EDITOR, "state": "active", "reason": None, "active": True, "can_confirm": False,
}]
# ---------------------------------------------------------------------------
# workflows
# ---------------------------------------------------------------------------
class TestWorkflowConsent:
def _setup(self, pg_conn):
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
agent_id, team_id = _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
return str(wf["id"]), agent_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_new_node_sponsorship_needs_confirmation(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) == 409
body = _body(resp)
assert body["code"] == CODE_CONFIRMATION_REQUIRED
assert {r["key"] for r in body["resources"]} == {f"tool:{tool}", f"source:{source}"}
assert body["audience"]["teams"] == ["T"]
row = WorkflowsRepository(pg_conn).get_by_id(wid)
assert row["resource_sponsors"] == {}
assert row["current_graph_version"] in (None, 0, 1)
confirmed = {**_wf_body(tool, source), "confirm_sponsor": [f"tool:{tool}", f"source:{source}"]}
resp = self._put(app, pg_conn, wid, EDITOR, confirmed)
assert _status(resp) == 200, _body(resp)
assert WorkflowsRepository(pg_conn).get_by_id(wid)["resource_sponsors"] == {
sponsor_key("tool", tool): EDITOR,
sponsor_key("source", source): EDITOR,
}
def test_use_only_node_resource_is_refused(self, app, pg_conn):
wid, _, _ = self._setup(pg_conn)
tool = _others_tool(pg_conn, _other_team(pg_conn, EDITOR), EDITOR, "viewer")
resp = self._put(app, pg_conn, wid, EDITOR, {**_wf_body(tool), "confirm_sponsor": [f"tool:{tool}"]})
assert _status(resp) == 403
assert _body(resp)["code"] == CODE_NOT_ALLOWED
def test_unrelated_confirmation_is_rejected(self, app, pg_conn):
wid, _, _ = self._setup(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
body = {**_wf_body(tool), "confirm_sponsor": [f"tool:{tool}", f"tool:{uuid.uuid4()}"]}
assert _status(self._put(app, pg_conn, wid, EDITOR, body)) == 400
def test_lost_sponsor_not_transferred_without_confirmation(self, app, pg_conn):
wid, agent_id, team_id = self._setup(pg_conn)
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, access_level="editor")
_add_agent_editor(pg_conn, team_id, agent_id, OTHER)
assert _status(self._put(app, pg_conn, wid, EDITOR,
{**_wf_body(tool), "confirm_sponsor": [f"tool:{tool}"]})) == 200
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
assert _status(self._put(app, pg_conn, wid, OTHER, _wf_body(tool))) == 200
row = WorkflowsRepository(pg_conn).get_by_id(wid)
assert row["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
assert active_sponsor(pg_conn, "workflow", row, "tool", tool) is None
assert _status(self._put(app, pg_conn, wid, OTHER,
{**_wf_body(tool), "confirm_sponsor": [f"tool:{tool}"]})) == 200
row = WorkflowsRepository(pg_conn).get_by_id(wid)
assert row["resource_sponsors"] == {sponsor_key("tool", tool): OTHER}
assert active_sponsor(pg_conn, "workflow", row, "tool", tool) == OTHER
def test_workflow_details_carry_state(self, app, pg_conn):
from docsgpt.api.user.workflows.routes import WorkflowDetail
wid, _, _ = self._setup(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert _status(self._put(app, pg_conn, wid, EDITOR,
{**_wf_body(tool), "confirm_sponsor": [f"tool:{tool}"]})) == 200
resp = _call(app, pg_conn, WorkflowDetail, "get", f"/api/workflows/{wid}", OWNER, args=(wid,))
[detail] = _body(resp)["data"]["resource_sponsors"]
assert detail["state"] == "active" and detail["reason"] is None and detail["name"] == "api_tool"