mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 00:14:02 +00:00
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:
1 parent
d4b30f4838
commit
335241e2e6
8 files changed
+923
-95
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user