Files
DocsGPT/docsgpt/api/user/resource_access.py
T
arc53-machine 335241e2e6 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.
2026-09-29 16:27:54 +01:00

861 lines
33 KiB
Python

"""The one access check for team-shared resources: roles, actions and switches.
Every shareable resource (``agent``, ``source``, ``tool``, ``prompt``) has an
owner and may be shared to teams as ``viewer`` or ``editor``. What each role
may do is a fixed table of *actions* per resource type (``ACTIONS``). The
owner can adjust a few of those rows on one resource with *switches*
(``SWITCHES``), stored in ``resource_share_settings``. A switch only ever moves
one action between two roles; it never touches owner-only actions such as
``manage_settings``.
Routes ask one question, ``require(conn, type, id, user, action)``, and get
back a :class:`ResourceAccess` (whose ``owner_id`` is the id to write as) or
an :class:`AccessDenied` carrying 404 (not visible) or 403 (visible, but the
role may not do this). List and get responses embed ``ResourceAccess.payload()``
(``access`` + ``allowed_actions``) so the frontend never re-derives the rules.
Access is resolved live on every call (grants JOIN ``team_members``), so a
revoked grant or membership denies on the next request.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Iterable, Optional
from sqlalchemy import Connection, text
from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
logger = logging.getLogger(__name__)
RESOURCE_TYPES = ("agent", "source", "tool", "prompt")
# Weakest role that may perform each action by default. ``owner`` rows are
# owner-only unless a switch below moves them.
ACTIONS: dict[str, dict[str, str]] = {
"agent": {
"use": "viewer", # chat with it
"pin": "viewer",
"view": "editor", # open the edit page and read its full config
"edit": "editor",
"publish": "editor",
"edit_policy": "editor", # guardrails and quotas
"view_logs": "editor",
"manage_schedules": "editor",
"export": "editor",
"manage_access_details": "editor", # API key, webhook, public link
"move_folder": "owner",
"share": "owner",
"delete": "owner",
"manage_settings": "owner",
},
"source": {
"use": "viewer", # browse files, chunks, graph, wiki; test retrieval; attach to agents
"view_config": "editor",
"edit": "editor", # chunks, files, wiki, config, sync, reingest, GraphRAG, convert
"reconnect": "owner", # change the connector account
"share": "owner",
"delete": "owner",
"manage_settings": "owner",
},
"tool": {
"use": "viewer", # see it and run it inside the owner's shared agents
"use_in_own": "viewer", # add it to my own agents and chats
"edit": "editor", # name, action descriptions, parameters, approval
"edit_credentials": "editor", # secrets, URL, auth (write-only); OAuth servers stay owner-only
"share": "owner",
"delete": "owner",
"manage_settings": "owner",
},
"prompt": {
"use": "viewer", # read it and use it in my own agents
"duplicate": "editor",
"edit": "editor",
"share": "owner",
"delete": "owner",
"manage_settings": "owner",
},
}
@dataclass(frozen=True)
class Switch:
"""One owner switch: moves ``action`` to ``role_on`` or ``role_off``."""
key: str
default: bool
action: str
role_on: str
role_off: str
# Order is the order the share dialog lists them in.
SWITCHES: dict[str, tuple[Switch, ...]] = {
"agent": (
Switch("editors_can_share", False, "share", "editor", "owner"),
Switch("editors_can_delete", False, "delete", "editor", "owner"),
Switch("editors_can_manage_access_details", True, "manage_access_details", "editor", "owner"),
Switch("viewers_can_see_logs", False, "view_logs", "viewer", "editor"),
),
"source": (
Switch("editors_can_share", False, "share", "editor", "owner"),
Switch("editors_can_delete", False, "delete", "editor", "owner"),
Switch("viewers_can_see_config", True, "view_config", "viewer", "editor"),
),
"tool": (
Switch("editors_can_change_credentials", True, "edit_credentials", "editor", "owner"),
Switch("editors_can_share", False, "share", "editor", "owner"),
Switch("viewers_can_use_in_agents", True, "use_in_own", "viewer", "editor"),
),
"prompt": (
Switch("editors_can_share", False, "share", "editor", "owner"),
Switch("viewers_can_duplicate", True, "duplicate", "viewer", "editor"),
),
}
_RANK = {"viewer": 1, "editor": 2, "owner": 3}
_REPO_FOR_TYPE = {
"agent": AgentsRepository,
"source": SourcesRepository,
"prompt": PromptsRepository,
"tool": UserToolsRepository,
}
class AccessDenied(Exception):
"""Raised by :func:`require`; ``status`` is 404 (not visible) or 403."""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
def default_settings(resource_type: str) -> dict[str, bool]:
"""Every switch of ``resource_type`` at its default."""
return {s.key: s.default for s in SWITCHES.get(resource_type, ())}
def _merge(resource_type: str, stored: Optional[dict]) -> dict[str, bool]:
merged = default_settings(resource_type)
for key, value in (stored or {}).items():
if key in merged and isinstance(value, bool):
merged[key] = value
return merged
def role_table(resource_type: str, settings: Optional[dict]) -> dict[str, str]:
"""``action -> weakest role`` for one resource, switches applied."""
table = dict(ACTIONS[resource_type])
merged = _merge(resource_type, settings)
for switch in SWITCHES.get(resource_type, ()):
table[switch.action] = switch.role_on if merged[switch.key] else switch.role_off
return table
def allowed_actions(
resource_type: str, access: Optional[str], settings: Optional[dict]
) -> set[str]:
"""The actions ``access`` may perform on a resource with these switches."""
if access not in _RANK or resource_type not in ACTIONS:
return set()
rank = _RANK[access]
return {action for action, role in role_table(resource_type, settings).items() if rank >= _RANK[role]}
def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
"""The switches as ``[{key, value, default}]`` in display order."""
merged = _merge(resource_type, settings)
return [
{"key": s.key, "value": merged[s.key], "default": s.default}
for s in SWITCHES.get(resource_type, ())
]
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
"""The resource's switches, defaults filled in."""
rid = canonical_uuid(str(resource_id))
return settings_many(conn, resource_type, [rid])[rid]
def settings_many(
conn: Connection, resource_type: str, resource_ids: Iterable[str]
) -> dict[str, dict[str, bool]]:
"""``resource_id -> switches`` for many resources in one query.
Keys are canonical (lowercase) UUIDs, the form Postgres returns.
"""
ids = [canonical_uuid(str(r)) for r in resource_ids]
out = {rid: default_settings(resource_type) for rid in ids}
uuids = [rid for rid in ids if looks_like_uuid(rid)]
if not uuids:
return out
rows = conn.execute(
text(
"""
SELECT resource_id, settings FROM resource_share_settings
WHERE resource_type = :t AND resource_id = ANY(CAST(:ids AS uuid[]))
"""
),
{"t": resource_type, "ids": uuids},
).fetchall()
for rid, stored in rows:
out[str(rid)] = _merge(resource_type, stored)
return out
def set_settings(
conn: Connection, resource_type: str, resource_id: str, changes: dict, updated_by: str
) -> dict[str, bool]:
"""Merge ``changes`` into the resource's switches and return the result.
Raises:
ValueError: an unknown key or a non-boolean value.
"""
known = default_settings(resource_type)
for key, value in changes.items():
if key not in known:
raise ValueError(f"Unknown setting: {key}")
if not isinstance(value, bool):
raise ValueError(f"Setting {key} must be true or false")
merged = {**settings_for(conn, resource_type, resource_id), **changes}
conn.execute(
text(
"""
INSERT INTO resource_share_settings (resource_type, resource_id, settings, updated_by)
VALUES (:t, CAST(:id AS uuid), CAST(:s AS jsonb), :by)
ON CONFLICT (resource_type, resource_id)
DO UPDATE SET settings = EXCLUDED.settings, updated_by = EXCLUDED.updated_by,
updated_at = now()
"""
),
{"t": resource_type, "id": resource_id, "s": json.dumps(merged), "by": updated_by},
)
return merged
def delete_settings(conn: Connection, resource_type: str, resource_id: str) -> None:
"""Drop a deleted resource's switches (the table has no FK to cascade)."""
if looks_like_uuid(resource_id):
conn.execute(
text("DELETE FROM resource_share_settings WHERE resource_type = :t AND resource_id = CAST(:id AS uuid)"),
{"t": resource_type, "id": resource_id},
)
@dataclass(frozen=True)
class ResourceAccess:
"""What one user may do on one resource."""
resource_type: str
resource_id: str
access: str # owner | editor | viewer
owner_id: str # the id to read and write the resource as
settings: dict = field(default_factory=dict)
actions: frozenset = frozenset()
def can(self, action: str) -> bool:
return action in self.actions
def payload(self) -> dict:
"""The fields every API response embeds for this resource."""
return {"access": self.access, "allowed_actions": sorted(self.actions)}
def build(resource_type: str, resource_id: str, access: str, owner_id: str, settings: dict) -> ResourceAccess:
"""A :class:`ResourceAccess` from already-known parts (list endpoints)."""
merged = _merge(resource_type, settings)
return ResourceAccess(
resource_type=resource_type,
resource_id=str(resource_id),
access=access,
owner_id=owner_id,
settings=merged,
actions=frozenset(allowed_actions(resource_type, access, merged)),
)
def payload_for(resource_type: str, access: Optional[str], settings: Optional[dict]) -> dict:
"""``access`` + ``allowed_actions`` without an owner lookup (list endpoints)."""
return {
"access": access,
"allowed_actions": sorted(allowed_actions(resource_type, access, settings)),
}
def resolve(
conn: Connection, resource_type: str, resource_id: str, user_id: str
) -> Optional[ResourceAccess]:
"""The caller's access to a resource, or None when they can't see it."""
repo_cls = _REPO_FOR_TYPE.get(resource_type)
if repo_cls is None or not resource_id or not user_id:
return None
# Postgres matches any casing but returns lowercase; canonicalise so the
# switch lookup (keyed by the returned id) can't miss.
resource_id = canonical_uuid(str(resource_id))
owned = repo_cls(conn).get_any(resource_id, user_id)
if owned is not None:
rid = str(owned.get("id") or resource_id)
return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
# Only canonical UUIDs can carry a grant; casting anything else would
# poison the transaction.
if not looks_like_uuid(str(resource_id)):
return None
level = TeamScopeRepository(conn).effective_access(user_id, resource_type, str(resource_id))
if level is None:
return None
grants = TeamResourceGrantsRepository(conn).list_for_resource(resource_type, str(resource_id))
if not grants:
return None
# Every grant row carries the same denormalised owner id.
owner_id = grants[0].get("owner_id")
return build(resource_type, str(resource_id), level, owner_id, settings_for(conn, resource_type, str(resource_id)))
def require(
conn: Connection, resource_type: str, resource_id: str, user_id: str, action: str
) -> ResourceAccess:
"""Resolve and check one action.
Raises:
KeyError: ``action`` is not an action of ``resource_type`` (a bug).
AccessDenied: 404 when the resource isn't visible, 403 when the
caller's role may not perform ``action``.
"""
if action not in ACTIONS.get(resource_type, {}):
raise KeyError(f"{resource_type} has no action {action!r}")
ra = resolve(conn, resource_type, resource_id, user_id)
if ra is None:
raise AccessDenied(404, f"{resource_type.capitalize()} not found")
if not ra.can(action):
raise AccessDenied(403, "Your access to this item doesn't allow that")
return ra
# --- Resource sponsors ------------------------------------------------------
#
# An agent (or workflow) runs as its owner, so a source, prompt or tool it
# references is authorized against the owner. When a team editor attaches one
# the owner can't use, the editor 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 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."""
return f"{resource_type}:{resource_id}"
def can_use_ref(conn: Connection, resource_type: str, resource_id: str, user_id: Optional[str]) -> bool:
"""Whether ``user_id`` may have ``resource_id`` run inside something they hold.
Args:
conn: Open database connection.
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
user_id: The would-be principal.
Returns:
True when the user owns the resource or a team grant gives them
``use`` (``use_in_own`` for a tool).
"""
if not user_id or not resource_id:
return False
ra = resolve(conn, resource_type, str(resource_id), user_id)
return ra is not None and ra.can(REF_USE_ACTION[resource_type])
def 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``.
A workflow is edited through an agent of its owner that uses it, so the
check is ``edit`` on any such agent (mirrors the workflow routes).
"""
if holder_type == "agent":
ra = resolve(conn, "agent", str(holder["id"]), user_id)
return ra is not None and ra.can("edit")
if holder_type == "workflow":
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
return False
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]:
"""The sponsor a holder may run ``resource_id`` as, checked live.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row (needs ``id``, ``user_id``, ``resource_sponsors``).
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
Returns:
The sponsor's id when one is recorded, still edits the holder and
still may sponsor the resource (owns or edits it); else 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(
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
) -> Optional[str]:
"""The user a holder's referenced resource is authorized as, or None.
The owner when they may use it (the default), else a live sponsor.
"""
if not holder:
return None
owner = holder.get("user_id")
if can_use_ref(conn, resource_type, str(resource_id), owner):
return owner
return active_sponsor(conn, holder_type, holder, resource_type, resource_id)
def _sponsorable(resource_type: str, resource_id: str) -> bool:
"""Only real rows need a principal: skip presets and builtin tool ids."""
rid = str(resource_id or "")
if not looks_like_uuid(rid):
return False
if resource_type == "tool":
# Lazy: default_tools imports this module lazily too.
from docsgpt.agents.default_tools import is_synthesized_tool_id
return not is_synthesized_tool_id(rid)
return True
def agent_refs(agent: dict) -> list[tuple[str, str]]:
"""The ``(type, id)`` resources an agent row references."""
refs = [("source", str(s)) for s in [agent.get("source_id"), *(agent.get("extra_source_ids") or [])] if s]
if agent.get("prompt_id"):
refs.append(("prompt", str(agent["prompt_id"])))
refs.extend(("tool", str(t)) for t in agent.get("tools") or [] if t)
return refs
def 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]],
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;
* 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.
holder_type: ``agent`` or ``workflow``.
holder: The holder row before the save (None when creating it).
owner_id: The holder's owner.
caller: The user saving.
refs: Every ``(type, id)`` the holder references after the save.
previous_refs: Every ``(type, id)`` it referenced before the save.
confirmed: ``"<type>:<id>"`` keys the caller agreed to sponsor.
Returns:
SponsorPlan: The map to store and anything that refuses the save.
"""
previous = (holder or {}).get("resource_sponsors") or {}
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 seen or not _sponsorable(resource_type, resource_id):
continue
seen.add(key)
if can_use_ref(conn, resource_type, resource_id, owner_id):
continue
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):
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 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: 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:
return []
user_ids = sorted({u for u in sponsors.values() if u})
labels = dict(
conn.execute(
text(
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
"AND email IS NOT NULL AND email <> ''"
),
{"ids": user_ids},
).fetchall()
) if user_ids else {}
entries = []
for key, user_id in sponsors.items():
resource_type, _, resource_id = key.partition(":")
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,
"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