Files
DocsGPT/docsgpt/api/user/resource_access.py
T
arc53-machine 2359e4546b Say which attached resources stopped running and why
The agent and workflow reads now return resource_states to people who may
edit them: every attached tool, source and prompt (and workflow node tool
and source) with active or stopped and the reason: deleted,
owner_lost_access, the sponsor reasons, connection_needs_reconnect,
connection_removed or connector_disabled. Each entry names the sponsor,
someone other than the reader who can fix it, the service for a connection
reason, and whether the reader may take it over or reconnect it. When
something can be taken over, sponsor_audience says who it would reach.

The state comes from the checks the run itself uses (ref_access,
resolve_holder_tool and the tool's connection as the run resolves it), so
the page and the run can't disagree. A run that leaves a resource out logs
resource_stopped with the holder, type, id and reason.

The workflow read gives sponsor details, run state and node resource names
only to people who may edit it, and names only resources the workflow runs,
someone sponsored, or the reader can see. Owner saves and new workflows
now refuse node tools and sources the owner can't use, like editor saves.
2026-09-29 17:11:58 +01:00

1279 lines
50 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.
"""
return ref_access(conn, holder_type, holder, resource_type, resource_id).principal
# --- Run state of attached resources ----------------------------------------
#
# One check decides whether an attached resource runs, for the run and for the
# edit page alike (``ref_access``, ``resolve_holder_tool``), so the page never
# says a resource runs when the run drops it, or the other way round.
# Why an attached resource doesn't run (``resource_states`` ``reason``), next
# to the sponsor reasons above.
REASON_DELETED = "deleted"
REASON_OWNER_LOST_ACCESS = "owner_lost_access"
REASON_CONNECTION_NEEDS_RECONNECT = "connection_needs_reconnect"
REASON_CONNECTION_REMOVED = "connection_removed"
REASON_CONNECTOR_DISABLED = "connector_disabled"
_REF_TABLES = {"source": "sources", "prompt": "prompts", "tool": "user_tools"}
@dataclass(frozen=True)
class RefAccess:
"""Who a holder runs one referenced resource as, or why it doesn't run.
Attributes:
principal: The user it is authorized as (the holder's owner or a live
sponsor); None when it doesn't run.
reason: None while it runs; else :data:`REASON_DELETED`,
:data:`REASON_OWNER_LOST_ACCESS` or a sponsor reason.
sponsor: The recorded sponsor, running or not.
"""
principal: Optional[str]
reason: Optional[str] = None
sponsor: Optional[str] = None
def _ref_exists(conn: Connection, resource_type: str, resource_id: str) -> bool:
"""Whether a row with this id exists, whoever owns it."""
table = _REF_TABLES.get(resource_type)
if table is None or not looks_like_uuid(str(resource_id)):
return False
return conn.execute(
text(f"SELECT 1 FROM {table} WHERE id = CAST(:id AS uuid)"), {"id": str(resource_id)}
).first() is not None
def ref_access(
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
) -> RefAccess:
"""Whether and as whom a holder runs one referenced resource.
The run and the edit page both ask this. The owner runs it when they may
use it, else a live sponsor does; otherwise it is stopped, because the row
is gone, a recorded sponsor no longer qualifies, or the owner lost access.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row (needs ``user_id``; ``id`` and
``resource_sponsors`` for sponsors).
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
Returns:
RefAccess: The principal, or the reason it doesn't run.
"""
if not holder:
return RefAccess(None, REASON_OWNER_LOST_ACCESS)
rid = str(resource_id)
owner = holder.get("user_id")
if can_use_ref(conn, resource_type, rid, owner):
return RefAccess(owner)
sponsor, sponsor_reason = sponsor_state(conn, holder_type, holder, resource_type, rid)
if sponsor and sponsor_reason is None:
return RefAccess(sponsor, None, sponsor)
if not _ref_exists(conn, resource_type, rid):
return RefAccess(None, REASON_DELETED, sponsor)
return RefAccess(None, sponsor_reason or REASON_OWNER_LOST_ACCESS, sponsor)
def resolve_holder_tool(
conn: Connection, holder_type: str, holder: Optional[dict], tool_id: str, *, tools_repo=None
) -> tuple[Optional[dict], RefAccess]:
"""The tool row a holder runs ``tool_id`` with, and its access.
Builtin and default tool ids resolve to their synthesized rows. A
``user_tools`` row resolves as the holder's owner, else as its live
sponsor (see :func:`ref_access`); the row is the tool owner's either way.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
tool_id: The referenced tool id.
tools_repo: A ``UserToolsRepository`` on ``conn`` to reuse.
Returns:
``(row, access)``: the row, None when it doesn't run, and why.
"""
# Lazy: default_tools imports this module lazily too.
from docsgpt.agents.default_tools import resolve_tool_by_id
repo = tools_repo or UserToolsRepository(conn)
owner = (holder or {}).get("user_id")
row = resolve_tool_by_id(tool_id, owner, user_tools_repo=repo)
if row is not None:
return row, RefAccess(owner)
access = ref_access(conn, holder_type, holder, "tool", str(tool_id))
if access.principal:
row = resolve_tool_by_id(tool_id, access.principal, user_tools_repo=repo)
if row is None:
reason = access.reason or REASON_DELETED
return None, RefAccess(None, reason, access.sponsor)
return row, access
def log_stopped(
holder_type: str, holder: Optional[dict], resource_type: str, resource_id, reason: Optional[str]
) -> None:
"""Log one attached resource a run leaves out, greppable by ``resource_stopped``.
Args:
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
reason: Why it doesn't run.
"""
holder_id = str((holder or {}).get("id") or "")
logger.info(
"resource_stopped holder=%s:%s type=%s id=%s reason=%s",
holder_type, holder_id, resource_type, resource_id, reason,
extra={
"event": "resource_stopped",
"holder_type": holder_type,
"holder_id": holder_id,
"resource_type": resource_type,
"resource_id": str(resource_id),
"reason": reason,
},
)
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;
* a newly attached one ignores any record left from before it was
removed, so a stale sponsor never vouches for it again.
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)
# A record only vouches for a resource that stayed attached: one left
# behind by a path that dropped the resource never covers it again.
live = None if is_new else 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 not is_new and previous.get(key):
plan.sponsors[key] = previous[key]
plan.unexpected = sorted(confirmed - eligible)
return plan
def prune_sponsors(sponsors: Optional[dict], refs: Iterable[tuple[str, str]]) -> dict:
"""``sponsors`` without the keys of resources no longer referenced.
For paths that rewrite a holder's references without going through
:func:`plan_sponsors` (YAML import, a workflow graph written by import).
Args:
sponsors: The stored ``resource_sponsors`` map.
refs: Every ``(type, id)`` the holder references now.
Returns:
dict: The map to store.
"""
keep = {sponsor_key(t, str(i).lower()) for t, i in refs}
return {k: v for k, v in (sponsors or {}).items() if k.lower() in keep}
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
def holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_id: Optional[str]) -> bool:
"""Whether ``user_id`` may edit the agent or workflow ``holder``.
Its owner always may; anyone else needs ``edit`` on the agent (for a
workflow, on one of its owner's agents that run it).
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
user_id: The reader.
Returns:
bool: Whether they may edit it.
"""
if not user_id or not holder:
return False
if user_id == holder.get("user_id"):
return True
return _holder_editable_by(conn, holder_type, holder, user_id)
def _ref_rows(conn: Connection, refs: Iterable[tuple[str, str]]) -> dict[str, dict]:
"""``"<type>:<id>" -> {name, user_id}`` for referenced rows, owner-agnostic, per type in one query."""
queries = {
"source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
"prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
"tool": (
"SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name), user_id "
"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, dict] = {}
for resource_type, ids in by_type.items():
for rid, name, owner in conn.execute(text(queries[resource_type]), {"ids": ids}).fetchall():
out[sponsor_key(resource_type, str(rid))] = {"name": name, "user_id": owner}
return out
def _user_labels(conn: Connection, user_ids: Iterable[Optional[str]]) -> dict[str, str]:
"""``user_id -> email`` for the ones with an email on file."""
ids = sorted({u for u in user_ids if u})
if not ids:
return {}
return dict(
conn.execute(
text(
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
"AND email IS NOT NULL AND email <> ''"
),
{"ids": ids},
).fetchall()
)
def _connection_state(conn: Connection, tool: dict, owner: Optional[str], policies_box: list) -> tuple:
"""``(reason, connection)`` for a connection-backed tool the holder runs as ``owner``."""
from docsgpt.connectors import catalog, service
from docsgpt.connectors.resolve import connection_stop_reason, resolve_connection
if not tool.get("connection_id") and not catalog.definition_for_tool(tool.get("name") or ""):
return None, None
if not policies_box:
policies_box.append(service.load_policies(conn))
resolved = resolve_connection(tool, owner, conn=conn, policies=policies_box[0])
reason = connection_stop_reason(tool, resolved)
if reason is None:
return None, None
if resolved is not None:
connection = {
"id": resolved.connection_id,
"connector_key": resolved.connector_key,
"name": resolved.connector_name,
}
else:
definition = catalog.definition_for_tool(tool.get("name") or "")
connection = {"id": None, "connector_key": definition.key, "name": definition.name}
return reason, connection
def resource_states(
conn: Connection,
holder_type: str,
holder: dict,
refs: Iterable[tuple[str, str]],
viewer: Optional[str],
) -> list[dict]:
"""Whether each resource a holder references runs, for its edit page.
Uses the run's own checks (:func:`ref_access`, :func:`resolve_holder_tool`
and the tool's connection as the run resolves it for the owner), so a
resource shown as stopped is one the run leaves out. Presets and builtin
tools always run and are not listed. Only for readers who may edit the
holder: the caller checks that.
A name is given only for a resource that runs, that the reader can see
themselves, that someone sponsored on the holder, or that is attached to
an agent (agent saves check every reference), so a reference to someone
else's resource never reveals its name.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
refs: The ``(type, id)`` resources it references.
viewer: The user reading the page.
Returns:
list: Per resource ``{key, type, id, name, state, reason, sponsor,
contact, connection, can_confirm, can_reconnect}``. ``state`` is
``active`` or ``stopped``; ``reason`` (None while active) is one of
``deleted``, ``owner_lost_access``, the sponsor reasons,
``connection_needs_reconnect``, ``connection_removed`` or
``connector_disabled``. ``sponsor`` and ``contact`` are
``{user_id, label}`` or None: the recorded sponsor, and someone other
than the reader who can fix it. ``connection`` (``{id,
connector_key, name}``) names the service for a connection reason.
``can_confirm``: the reader may take it over on their next save;
``can_reconnect``: the reader owns the connection that needs signing
in again.
"""
owner = holder.get("user_id")
seen: set[str] = set()
pairs: list[tuple[str, str]] = []
for resource_type, resource_id in refs:
rid = str(resource_id).lower()
key = sponsor_key(resource_type, rid)
if key in seen or resource_type not in REF_USE_ACTION or not _sponsorable(resource_type, rid):
continue
seen.add(key)
pairs.append((resource_type, rid))
if not pairs:
return []
rows = _ref_rows(conn, pairs)
recorded = {k.lower(): v for k, v in (holder.get("resource_sponsors") or {}).items()}
viewer_edits = bool(viewer and viewer != owner and _holder_editable_by(conn, holder_type, holder, viewer))
tools_repo = UserToolsRepository(conn)
policies_box: list = []
entries = []
for resource_type, rid in pairs:
key = sponsor_key(resource_type, rid)
info = rows.get(key) or {}
connection = None
if resource_type == "tool":
tool_row, access = resolve_holder_tool(conn, holder_type, holder, rid, tools_repo=tools_repo)
reason = access.reason
if tool_row is not None and reason is None:
reason, connection = _connection_state(conn, tool_row, owner, policies_box)
else:
access = ref_access(conn, holder_type, holder, resource_type, rid)
reason = access.reason
sponsor = recorded.get(key)
sponsor = sponsor if sponsor and sponsor != owner else None
resource_owner = info.get("user_id")
can_confirm = bool(
reason in (REASON_OWNER_LOST_ACCESS, REASON_CANNOT_EDIT_HOLDER, REASON_CANNOT_EDIT_RESOURCE)
and viewer_edits
and can_sponsor_ref(conn, resource_type, rid, viewer)
)
contact = None
if reason == REASON_OWNER_LOST_ACCESS:
# The owner asks whoever shared it; an editor asks the owner.
contact = resource_owner if viewer == owner else owner
elif reason in (REASON_CONNECTION_NEEDS_RECONNECT, REASON_CONNECTION_REMOVED):
contact = resource_owner
if contact == viewer:
contact = None
name_visible = bool(
reason is None
or sponsor
or holder_type == "agent"
or (viewer and resolve(conn, resource_type, rid, viewer) is not None)
)
entries.append({
"key": key,
"type": resource_type,
"id": rid,
"name": info.get("name") if name_visible else None,
"state": "active" if reason is None else "stopped",
"reason": reason,
"sponsor": sponsor,
"contact": contact,
"connection": connection,
"can_confirm": can_confirm,
"can_reconnect": bool(
reason == REASON_CONNECTION_NEEDS_RECONNECT
and connection
and connection.get("id")
and viewer
and viewer == resource_owner
),
})
labels = _user_labels(conn, [u for e in entries for u in (e["sponsor"], e["contact"])])
for entry in entries:
for field_name in ("sponsor", "contact"):
user_id = entry[field_name]
entry[field_name] = {"user_id": user_id, "label": labels.get(user_id) or user_id} if user_id else None
return entries
def visible_ref_ids(
conn: Connection, holder_type: str, holder: dict, refs: Iterable[tuple[str, str]], viewer: Optional[str]
) -> set[str]:
"""Keys of references whose names ``viewer`` may read on the holder's edit page.
Those the holder runs (its owner or a live sponsor may use them), those
someone sponsored on it, and those the reader can see themselves.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
refs: ``(type, id)`` pairs.
viewer: The reader.
Returns:
set: ``"<type>:<id>"`` keys, ids lowercased.
"""
recorded = {k.lower() for k in (holder.get("resource_sponsors") or {})}
out: set[str] = set()
for resource_type, resource_id in refs:
rid = str(resource_id).lower()
key = sponsor_key(resource_type, rid)
if key in out or resource_type not in REF_USE_ACTION:
continue
if (
key in recorded
or ref_access(conn, holder_type, holder, resource_type, rid).principal
or (viewer and resolve(conn, resource_type, rid, viewer) is not None)
):
out.add(key)
return out
def sponsor_audience(
conn: Connection, holder_type: str, holder: dict, states: list[dict], sponsors: list[dict]
) -> Optional[dict]:
"""The holder's audience when the reader may take something over, else None.
A take-over runs the resource with the reader's access for everyone who
uses the holder, so the page shows them who that is before they agree.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
states: :func:`resource_states` for the reader.
sponsors: :func:`sponsor_details` for the reader.
Returns:
dict or None: :func:`holder_audience`, when any item has ``can_confirm``.
"""
if not any(item.get("can_confirm") for item in [*states, *sponsors]):
return None
return holder_audience(conn, holder_type, holder)