"""What a personal access token may call: the scope and resource rule table. Authorization for PATs is central and deny by default. ``RULES`` maps a Flask route (its rule string and method) to the scope it needs; a PAT request to a route that is not listed is refused, so a new endpoint is unreachable by token until someone classifies it here. ``tests/api/test_pat_rules.py`` fails when a registered route is in neither ``RULES`` nor ``DENIED``. A token may also carry a resource filter (``{"agents": [ids]}``). For a restricted family the rule must be able to prove the request stays inside the allowlist: it names where the id travels (``ids``), or declares that the route filters its own listing (``listing``), or delegates to the route (``in_route``). Anything else, creation included, is refused. ``refs`` cover ids of *other* families a route accepts (an agent update naming a source), and ``blocked_by`` closes routes whose rows hang off a family the rule cannot see (a schedule belongs to an agent). Session (JWT) callers never pass through here. """ from __future__ import annotations import json import uuid from dataclasses import dataclass from typing import Any, Callable, Iterable, Optional from docsgpt.api.pat.tokens import is_pat VIEW, QUERY, JSON, FORM, BODY = "view", "query", "json", "form", "body" Locator = tuple[str, str] @dataclass(frozen=True) class Rule: """Requirement for one route+method. ``scopes`` is any-of; empty means any valid token.""" scopes: tuple[str, ...] = () family: Optional[str] = None ids: tuple[Locator, ...] = () refs: tuple[tuple[str, Locator], ...] = () listing: bool = False open: bool = False in_route: bool = False blocked_by: tuple[str, ...] = () check: Optional[Callable[[Any, dict, Optional[str]], Optional[str]]] = None def _rule(scope: Optional[str] = None, *ids: Locator, any_of: tuple[str, ...] = (), **kwargs) -> Rule: scopes = any_of or ((scope,) if scope else ()) family = kwargs.pop("family", None) if family is None and scope: family = scope.partition(":")[0] return Rule(scopes=scopes, family=family, ids=tuple(ids), **kwargs) #: Scopes that admit a token to message replay. Shared by the Flask tail route #: below and its ASGI sibling GET /api/messages//events (docsgpt/api/async_sse.py), #: which sits outside this table. MESSAGE_REPLAY_SCOPES = ("conversations:read", "chat:run") _ALL_FAMILIES = ("agents", "sources", "prompts", "tools", "workflows") _WORKFLOW_CONTENT_FAMILIES = ("sources", "tools", "prompts") # A route reached through an agent id can prove the agent; nothing else about it. _NON_AGENT_FAMILIES = ("sources", "prompts", "tools", "workflows") # Ids of other families that agent create/update accept in their JSON-or-form body. _AGENT_BODY_REFS = ( ("sources", (BODY, "source")), ("sources", (BODY, "sources")), ("prompts", (BODY, "prompt_id")), ("tools", (BODY, "tools")), ("workflows", (BODY, "workflow")), ) def _conversation_agent_id(conversation_id: str, user_id: Optional[str]) -> tuple[bool, str]: """``(found, agent_id)`` for a conversation the user can reach; ``agent_id`` is "" when it has none.""" from docsgpt.storage.db.repositories.conversations import ConversationsRepository from docsgpt.storage.db.session import db_readonly if not user_id: return False, "" try: with db_readonly() as conn: row = ConversationsRepository(conn).get_any(str(conversation_id), user_id) except Exception: return False, "" if not row: return False, "" return True, str(row.get("agent_id") or "") def _chat_check(request, resource_filter: dict, user_id: Optional[str]) -> Optional[str]: """Keep a restricted token's chat traffic inside its allowlists. An agent brings its own sources, prompt and tools, which this table cannot see, so a restricted token must name an allowed agent. The one exception is a token restricted on sources only, which may chat against allowed sources directly. Everything that could swap in another agent or another set of resources is refused: an agent ``api_key``, an inline workflow, and a ``conversation_id`` that belongs to a different agent (the server would otherwise continue, append to, or resume tool calls of that conversation). Chat executes tools: an agent's own, or the user's defaults when there is no agent. Neither can be held to a tools allowlist from here, so a token restricted on tools cannot chat at all. """ body = _json_body(request) if "tools" in resource_filter: return "A token restricted to specific tools cannot use chat endpoints" if body.get("api_key"): return "A restricted token cannot chat with an agent API key; pass agent_id" if body.get("workflow"): # An inline workflow graph (builder preview) can reference any resource. return "A restricted token cannot run an inline workflow" agent_ids = _as_ids(body.get("agent_id")) if "agents" in resource_filter: if len(agent_ids) != 1: return "This token is restricted to specific agents; pass agent_id" # the agent id itself is verified through ``refs`` elif set(resource_filter) - {"sources"}: return "Restrict this token to specific agents to use chat endpoints" elif agent_ids: return "This token is restricted to specific sources and cannot run agents" conversation_id = body.get("conversation_id") if conversation_id: found, conversation_agent = _conversation_agent_id(conversation_id, user_id) expected = agent_ids[0] if agent_ids else "" if not found or _canonical(conversation_agent) != _canonical(expected): return "This conversation does not belong to the agent this token may use" return None def _agent_body_check(request, resource_filter: dict, user_id: Optional[str]) -> Optional[str]: """A workflow pulls in its own sources, tools and prompts, which a reference check cannot see. A token restricted on any of those may attach a workflow to an agent only when it is also restricted on workflows, so the workflow is one its owner chose (``refs`` then verifies the id). """ if "workflows" in resource_filter or not set(resource_filter) & {"sources", "tools", "prompts"}: return None if _read(request, (BODY, "workflow")): return "A token restricted to specific sources, tools or prompts cannot attach a workflow to an agent" return None _CHAT = dict( family=None, refs=( ("agents", (JSON, "agent_id")), ("sources", (JSON, "active_docs")), ("prompts", (JSON, "prompt_id")), ("workflows", (JSON, "workflow_id")), ), check=_chat_check, ) RULES: dict[tuple[str, str], Rule] = { # Identity and public metadata: any valid token. ("/api/user/me", "GET"): _rule(open=True), ("/api/user/quota", "GET"): _rule(open=True), ("/api/health", "GET"): _rule(open=True), ("/api/config", "GET"): _rule(open=True), # Agents ("/api/get_agent", "GET"): _rule("agents:read", (QUERY, "id")), ("/api/get_agents", "GET"): _rule("agents:read", listing=True), ("/api/pinned_agents", "GET"): _rule("agents:read"), ("/api/shared_agents", "GET"): _rule("agents:read"), ("/api/template_agents", "GET"): _rule("agents:read", open=True), ("/api/export_agent", "GET"): _rule("agents:read", (QUERY, "id")), ("/api/guardrails/catalog", "GET"): _rule("agents:read", open=True), ("/api/guardrails/events", "GET"): _rule("agents:read", (QUERY, "agent_id")), ("/api/guardrails/summary", "GET"): _rule("agents:read", (QUERY, "agent_id")), ("/api/agents/folders/", "GET"): _rule("agents:read", open=True), ("/api/agents/folders/", "GET"): _rule("agents:read"), ("/api/create_agent", "POST"): _rule("agents:write", refs=_AGENT_BODY_REFS, check=_agent_body_check), ("/api/update_agent/", "PUT"): _rule( "agents:write", (VIEW, "agent_id"), refs=_AGENT_BODY_REFS, check=_agent_body_check ), ("/api/delete_agent", "DELETE"): _rule("agents:write", (QUERY, "id")), ("/api/adopt_agent", "POST"): _rule("agents:write"), ("/api/pin_agent", "POST"): _rule("agents:write", (QUERY, "id")), ("/api/remove_shared_agent", "DELETE"): _rule("agents:write", (QUERY, "id")), ("/api/share_agent", "PUT"): _rule("agents:write", (JSON, "id")), ("/api/import_agent/plan", "POST"): _rule("agents:write", in_route=True), ("/api/import_agent", "POST"): _rule("agents:write", in_route=True), ("/api/agents/folders/", "POST"): _rule("agents:write"), ("/api/agents/folders/", "PUT"): _rule("agents:write"), ("/api/agents/folders/", "DELETE"): _rule("agents:write"), ("/api/agents/folders/move_agent", "POST"): _rule("agents:write", (JSON, "agent_id")), ("/api/agents/folders/bulk_move", "POST"): _rule("agents:write", (JSON, "agent_ids")), ("/api/regenerate_agent_key/", "POST"): _rule("agents:keys", (VIEW, "agent_id")), ("/api/agent_webhook", "GET"): _rule("agents:keys", (QUERY, "id")), # Schedules hang off an agent, and a schedule runs that agent with a free-form # instruction and stores the output. The agent id proves the agent and nothing # else, so tokens restricted on any other family are kept out; routes that # carry only a schedule id prove nothing and are closed to every restricted token. ("/api/agents//schedules", "GET"): _rule( "schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES ), ("/api/agents//schedules/stats", "GET"): _rule( "schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES ), ("/api/agents//schedules", "POST"): _rule( "schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES ), ("/api/schedules/", "GET"): _rule("schedules:read", blocked_by=_ALL_FAMILIES), ("/api/schedules//runs", "GET"): _rule("schedules:read", blocked_by=_ALL_FAMILIES), ("/api/schedules//runs/", "GET"): _rule( "schedules:read", blocked_by=_ALL_FAMILIES ), ("/api/schedules/", "PUT"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), ("/api/schedules/", "PATCH"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), ("/api/schedules/", "DELETE"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), ("/api/schedules//run", "POST"): _rule("schedules:write", blocked_by=_ALL_FAMILIES), # Sources ("/api/sources", "GET"): _rule("sources:read", listing=True), # Counted and paged in SQL, so it cannot be narrowed here; restricted tokens use /api/sources. ("/api/sources/paginated", "GET"): _rule("sources:read"), ("/api/directory_structure", "GET"): _rule("sources:read", (QUERY, "id")), ("/api/get_chunks", "GET"): _rule("sources:read", (QUERY, "id")), ("/api/sources//wiki/pages", "GET"): _rule("sources:read", (VIEW, "source_id")), ("/api/sources//wiki/page", "GET"): _rule("sources:read", (VIEW, "source_id")), ("/api/sources//graph", "GET"): _rule("sources:read", (VIEW, "source_id")), ("/api/sources//graph/node/", "GET"): _rule( "sources:read", (VIEW, "source_id") ), ("/api/sources//graph/nodes", "GET"): _rule("sources:read", (VIEW, "source_id")), # Ingestion and attachment extraction both report through this poll. ("/api/task_status", "GET"): _rule(any_of=("sources:read", "sources:write", "chat:run"), open=True), ("/api/upload", "POST"): _rule("sources:write"), ("/api/remote", "POST"): _rule("sources:write"), ("/api/sources/wiki", "POST"): _rule("sources:write"), ("/api/delete_old", "GET"): _rule("sources:write", (QUERY, "source_id")), ("/api/manage_sync", "POST"): _rule("sources:write", (JSON, "source_id")), ("/api/sync_source", "POST"): _rule("sources:write", (JSON, "source_id")), ("/api/sources/reingest", "POST"): _rule("sources:write", (JSON, "source_id")), ("/api/manage_source_files", "POST"): _rule("sources:write", (FORM, "source_id")), ("/api/sources//config", "PATCH"): _rule("sources:write", (VIEW, "source_id")), ("/api/sources//wiki/page", "PUT"): _rule("sources:write", (VIEW, "source_id")), ("/api/sources//wiki/convert", "POST"): _rule("sources:write", (VIEW, "source_id")), ("/api/sources//graphrag/enable", "POST"): _rule( "sources:write", (VIEW, "source_id") ), ("/api/add_chunk", "POST"): _rule("sources:write", (JSON, "id")), ("/api/update_chunk", "PUT"): _rule("sources:write", (JSON, "id")), ("/api/delete_chunk", "DELETE"): _rule("sources:write", (QUERY, "id")), # Prompts ("/api/get_prompts", "GET"): _rule("prompts:read", listing=True), ("/api/get_single_prompt", "GET"): _rule("prompts:read", (QUERY, "id")), ("/api/create_prompt", "POST"): _rule("prompts:write"), ("/api/update_prompt", "POST"): _rule("prompts:write", (JSON, "id")), ("/api/delete_prompt", "POST"): _rule("prompts:write", (JSON, "id")), # Tools ("/api/available_tools", "GET"): _rule("tools:read", open=True), ("/api/get_tools", "GET"): _rule("tools:read", listing=True), ("/api/create_tool", "POST"): _rule("tools:write"), ("/api/parse_spec", "POST"): _rule("tools:write", open=True), ("/api/update_tool", "POST"): _rule("tools:write", (JSON, "id")), ("/api/update_tool_config", "POST"): _rule("tools:write", (JSON, "id")), ("/api/update_tool_actions", "POST"): _rule("tools:write", (JSON, "id")), ("/api/update_tool_status", "POST"): _rule("tools:write", (JSON, "id")), ("/api/delete_tool", "POST"): _rule("tools:write", (JSON, "id")), ("/api/mcp_server/test", "POST"): _rule("tools:write", open=True), ("/api/mcp_server/save", "POST"): _rule("tools:write", (JSON, "id")), # Models ("/api/models", "GET"): _rule(any_of=("models:read", "chat:run"), open=True), ("/api/user/models", "GET"): _rule("models:read"), ("/api/user/models/", "GET"): _rule("models:read"), ("/api/user/models", "POST"): _rule("models:write"), ("/api/user/models/", "PATCH"): _rule("models:write"), ("/api/user/models/", "DELETE"): _rule("models:write"), ("/api/user/models/test", "POST"): _rule("models:write"), ("/api/user/models//test", "POST"): _rule("models:write"), # Workflows # A workflow graph names sources, tools and prompts inside its nodes, out of reach of ``refs``. ("/api/workflows", "POST"): _rule("workflows:write", blocked_by=_WORKFLOW_CONTENT_FAMILIES), ("/api/workflows/", "GET"): _rule("workflows:read", (VIEW, "workflow_id")), ("/api/workflows/", "PUT"): _rule( "workflows:write", (VIEW, "workflow_id"), blocked_by=_WORKFLOW_CONTENT_FAMILIES ), ("/api/workflows/", "DELETE"): _rule("workflows:write", (VIEW, "workflow_id")), # Conversations and analytics span every agent and carry cited source text and tool # output, so they are closed to every restricted token. ("/api/get_conversations", "GET"): _rule("conversations:read", blocked_by=_ALL_FAMILIES), ("/api/search_conversations", "GET"): _rule("conversations:read", blocked_by=_ALL_FAMILIES), ("/api/get_single_conversation", "GET"): _rule("conversations:read", blocked_by=_ALL_FAMILIES), # A message cannot be tied to an allowlist from here, so any restricted token is kept out. ("/api/messages//tail", "GET"): _rule( any_of=MESSAGE_REPLAY_SCOPES, family=None, blocked_by=_ALL_FAMILIES ), ("/api/delete_conversation", "POST"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), ("/api/delete_all_conversations", "GET"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), ("/api/update_conversation_name", "POST"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), ("/api/feedback", "POST"): _rule("conversations:write", blocked_by=_ALL_FAMILIES), ("/api/get_message_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), ("/api/get_token_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), ("/api/get_feedback_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), ("/api/get_tool_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), ("/api/get_schedule_analytics", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), ("/api/get_user_logs", "POST"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), ("/api/traces", "GET"): _rule("analytics:read", blocked_by=_ALL_FAMILIES), # Teams (read only) ("/api/teams", "GET"): _rule("teams:read"), ("/api/teams/", "GET"): _rule("teams:read"), ("/api/teams//members", "GET"): _rule("teams:read"), ("/api/teams//grants", "GET"): _rule("teams:read"), ("/api/resource_shares", "GET"): _rule("teams:read"), ("/api/resource_settings", "GET"): _rule("teams:read"), # Chat ("/api/answer", "POST"): _rule("chat:run", **_CHAT), ("/stream", "POST"): _rule("chat:run", **_CHAT), ("/api/search", "POST"): _rule("chat:run", **_CHAT), ("/api/store_attachment", "POST"): _rule("chat:run", family=None), ("/api/sources//search", "POST"): _rule( "chat:run", family=None, refs=(("sources", (VIEW, "source_id")),) ), } #: Routes a PAT may never call, by exact rule string ("*" = every method) or prefix. #: Token management, admin, login flows and interactive OAuth handshakes need a #: signed-in session; the rest have no scope yet. Listing them keeps the #: classification test honest: a new route must land here or in ``RULES``. DENIED: dict[str, tuple[str, ...]] = { "/": ("*",), "/api/user/tokens": ("*",), "/api/user/tokens/": ("*",), "/api/user/tokens//regenerate": ("*",), "/api/generate_token": ("*",), "/api/combine": ("*",), "/api/download": ("*",), "/api/upload_index": ("*",), "/api/share": ("*",), "/api/shared_agent": ("*",), "/api/shared_conversation/": ("*",), "/api/webhooks/agents/": ("*",), "/api/images//": ("*",), "/api/mcp_server/callback": ("*",), "/api/mcp_server/auth_status": ("*",), "/api/artifact/": ("*",), "/api/artifacts": ("*",), "/api/artifacts/": ("*",), "/api/artifacts//restore": ("*",), "/api/artifacts//versions/": ("*",), "/api/stt": ("*",), "/api/stt/live/start": ("*",), "/api/stt/live/chunk": ("*",), "/api/stt/live/finish": ("*",), "/api/tts": ("*",), "/api/teams": ("POST",), "/api/teams/": ("PUT", "DELETE"), "/api/teams//members": ("POST",), "/api/teams//members/": ("*",), "/api/teams//grants": ("POST", "DELETE"), "/api/teams//transfer_owner": ("*",), "/api/resource_settings": ("PUT",), "/swagger.json": ("*",), } DENIED_PREFIXES = ( "/api/admin/", "/api/auth/oidc/", "/api/connectors/", "/api/connections", "/api/devices", "/scim/", "/static/", "/swaggerui/", "/v1/", ) def is_denied(rule: str, method: str) -> bool: if rule.startswith(DENIED_PREFIXES): return True methods = DENIED.get(rule) return bool(methods) and ("*" in methods or method in methods) def _json_body(request) -> dict: body = request.get_json(silent=True) return body if isinstance(body, dict) else {} def _as_ids(value: Any) -> list[str]: """Flatten whatever a route accepts as ids: a string, a JSON-encoded or plain list, or an ``{id}`` dict.""" if value is None or value == "": return [] if isinstance(value, dict): return _as_ids(value.get("id") or value.get("_id") or value.get("workflow_id")) if isinstance(value, (list, tuple)): out: list[str] = [] for item in value: out.extend(_as_ids(item)) return out text = str(value).strip() if text[:1] in "[{": try: return _as_ids(json.loads(text)) except ValueError: return [text] return [text] def _read(request, locator: Locator) -> list[str]: where, key = locator if where == VIEW: return _as_ids((request.view_args or {}).get(key)) if where == QUERY: return _as_ids(request.args.get(key)) if where == JSON: return _as_ids(_json_body(request).get(key)) if where == FORM: return _as_ids(request.form.get(key)) # BODY: routes that accept JSON or a multipart form interchangeably. if request.is_json: return _as_ids(_json_body(request).get(key)) return _as_ids(request.form.get(key)) def _canonical(value: str) -> str: try: return str(uuid.UUID(value)) except (ValueError, AttributeError, TypeError): return value def _all_allowed(ids: Iterable[str], allowed: Iterable[str]) -> bool: allowlist = {_canonical(a) for a in allowed} return all(_canonical(i) in allowlist for i in ids) def authorize(request, decoded_token: dict) -> Optional[tuple[dict, int]]: """Check a PAT request against the table. ``None`` allows; otherwise ``(body, status)``.""" url_rule = getattr(request, "url_rule", None) if url_rule is None: # Routing failed (unknown path or wrong method): no view will run, so # let Flask answer 404/405 instead of masking it with a 403. return None rule = RULES.get((url_rule.rule, request.method)) if rule is None: return ( { "success": False, "error": "not_available_to_tokens", "message": "This endpoint cannot be called with a personal access token", }, 403, ) granted = set(decoded_token.get("scopes") or []) if rule.scopes and not granted.intersection(rule.scopes): return ( { "success": False, "error": "insufficient_scope", "message": f"Token lacks the required scope: {' or '.join(rule.scopes)}", "required_scope": rule.scopes[0], }, 403, ) resource_filter = decoded_token.get("resource_filter") or {} if not resource_filter: return None reason = _check_resources(request, rule, resource_filter, decoded_token.get("sub")) if reason is None: return None return ({"success": False, "error": "resource_not_allowed", "message": reason}, 403) def _check_resources(request, rule: Rule, resource_filter: dict, user_id: Optional[str] = None) -> Optional[str]: for family in rule.blocked_by: if family in resource_filter: return f"This endpoint is not available to a token restricted to specific {family}" if rule.check is not None: reason = rule.check(request, resource_filter, user_id) if reason: return reason for family, locator in rule.refs: if family not in resource_filter: continue ids = _read(request, locator) if ids and not _all_allowed(ids, resource_filter[family]): return f"Token is not allowed to use one of the referenced {family}" family = rule.family if family is None or family not in resource_filter or rule.open or rule.listing or rule.in_route: return None ids = [i for locator in rule.ids for i in _read(request, locator)] if not ids: return f"This token is restricted to specific {family} and cannot use this endpoint" if not _all_allowed(ids, resource_filter[family]): return f"Token is not allowed to access this resource ({family})" return None def allowed_ids(request, family: str) -> Optional[set[str]]: """The caller's allowlist for ``family``, or ``None`` when unrestricted (or not a PAT). Used by listing routes (``listing=True``) and by ``in_route`` handlers. """ decoded = getattr(request, "decoded_token", None) if not is_pat(decoded): return None ids = (decoded.get("resource_filter") or {}).get(family) if ids is None: return None return {_canonical(str(i)) for i in ids} def filter_listing(request, family: str, items: list, key: str = "id") -> list: """Drop rows outside the caller's allowlist. Rows without a UUID id (built-in presets) are kept.""" allowed = allowed_ids(request, family) if allowed is None: return items kept = [] for item in items: value = str(item.get(key, "")) if not _is_uuid(value) or _canonical(value) in allowed: kept.append(item) return kept def _is_uuid(value: str) -> bool: try: uuid.UUID(value) except (ValueError, AttributeError, TypeError): return False return True def may_see_agent_keys(request) -> bool: """False for a token without ``agents:keys``: it must not receive a plaintext agent API key. Create, first publish and adopt all mint a key and used to return it, which handed a deploy token a secret that outlives the token's own revocation. """ decoded = getattr(request, "decoded_token", None) if not is_pat(decoded): return True return "agents:keys" in (decoded.get("scopes") or []) def mask_agent_key(key: Optional[str]) -> str: return f"{key[:4]}...{key[-4:]}" if key else ""