diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py index 3d849e05..65089a9a 100644 --- a/docsgpt/api/user/agents/routes.py +++ b/docsgpt/api/user/agents/routes.py @@ -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. diff --git a/docsgpt/api/user/resource_access.py b/docsgpt/api/user/resource_access.py index 7d3f5627..d9a35d48 100644 --- a/docsgpt/api/user/resource_access.py +++ b/docsgpt/api/user/resource_access.py @@ -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 ``":"`` 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 ``":"`` 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: ``":"`` keys the caller agreed to sponsor. Returns: - dict: ``":" -> 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: ``":" -> 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 diff --git a/docsgpt/api/user/workflows/routes.py b/docsgpt/api/user/workflows/routes.py index 3941e786..65625f80 100644 --- a/docsgpt/api/user/workflows/routes.py +++ b/docsgpt/api/user/workflows/routes.py @@ -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, diff --git a/docsgpt/services/search_service.py b/docsgpt/services/search_service.py index 3fd55e4e..7d6b0fb9 100644 --- a/docsgpt/services/search_service.py +++ b/docsgpt/services/search_service.py @@ -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. diff --git a/tests/api/test_agent_team_sharing.py b/tests/api/test_agent_team_sharing.py index 9a264ca4..0f8f92cd 100644 --- a/tests/api/test_agent_team_sharing.py +++ b/tests/api/test_agent_team_sharing.py @@ -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()), ] diff --git a/tests/api/user/agents/test_roles_access.py b/tests/api/user/agents/test_roles_access.py index 022e7130..d6110ce1 100644 --- a/tests/api/user/agents/test_roles_access.py +++ b/tests/api/user/agents/test_roles_access.py @@ -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) diff --git a/tests/api/user/test_resource_sponsors.py b/tests/api/user/test_resource_sponsors.py index d79cd29d..611a1909 100644 --- a/tests/api/user/test_resource_sponsors.py +++ b/tests/api/user/test_resource_sponsors.py @@ -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 diff --git a/tests/api/user/test_sponsor_consent.py b/tests/api/user/test_sponsor_consent.py new file mode 100644 index 00000000..d0c01db3 --- /dev/null +++ b/tests/api/user/test_sponsor_consent.py @@ -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"