"""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 becomes its *sponsor*: the holder row's # ``resource_sponsors`` maps ``":"`` to the editor's id, and at run # time the resource is authorized as the sponsor while they can still edit the # holder and still use the resource. A tool still runs with its own row's # credentials (the tool owner's), whoever the principal is. # 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"} 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 _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": 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: 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 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 can still use the resource; 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 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 sponsors_after_save( 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``. 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. 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. Returns: dict: ``":" -> user_id``. """ previous = (holder or {}).get("resource_sponsors") or {} out: dict[str, str] = {} for resource_type, resource_id in refs: key = sponsor_key(resource_type, resource_id) if key in out or not _sponsorable(resource_type, resource_id): continue 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 elif previous.get(key): out[key] = previous[key] return out def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> 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. 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). """ 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 {} out = [] 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 out.append( { "type": resource_type, "id": resource_id, "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, } ) return out