Files
DocsGPT/docsgpt/api/pat/rules.py
T
arc53-machine 3b44b6851d Let a wiki's owner decide whether API, widget and public-link runs edit it
A run from an agent's API key or widget acts as the agent's owner, so it
could rewrite any wiki the owner can edit. A new per-wiki setting,
wiki_outside_edits (off by default), decides whether such runs, and runs
from the agent's public link, get the wiki's edit actions. While it is off
they are offered only wiki_view, and the tool refuses writes itself after
reading the live setting. The owner changes it through the owner-only
/api/sources/<id>/wiki/settings route; tokens can read it but not change it.
2026-09-29 16:07:57 +01:00

549 lines
26 KiB
Python

"""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/<id>/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/<string:folder_id>", "GET"): _rule("agents:read"),
("/api/create_agent", "POST"): _rule("agents:write", refs=_AGENT_BODY_REFS, check=_agent_body_check),
("/api/update_agent/<string:agent_id>", "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/<string:folder_id>", "PUT"): _rule("agents:write"),
("/api/agents/folders/<string:folder_id>", "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/<string:agent_id>", "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/<string:agent_id>/schedules", "GET"): _rule(
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
),
("/api/agents/<string:agent_id>/schedules/stats", "GET"): _rule(
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
),
("/api/agents/<string:agent_id>/schedules", "POST"): _rule(
"schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
),
("/api/schedules/<string:schedule_id>", "GET"): _rule("schedules:read", blocked_by=_ALL_FAMILIES),
("/api/schedules/<string:schedule_id>/runs", "GET"): _rule("schedules:read", blocked_by=_ALL_FAMILIES),
("/api/schedules/<string:schedule_id>/runs/<string:run_id>", "GET"): _rule(
"schedules:read", blocked_by=_ALL_FAMILIES
),
("/api/schedules/<string:schedule_id>", "PUT"): _rule("schedules:write", blocked_by=_ALL_FAMILIES),
("/api/schedules/<string:schedule_id>", "PATCH"): _rule("schedules:write", blocked_by=_ALL_FAMILIES),
("/api/schedules/<string:schedule_id>", "DELETE"): _rule("schedules:write", blocked_by=_ALL_FAMILIES),
("/api/schedules/<string:schedule_id>/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/<string:source_id>/wiki/pages", "GET"): _rule("sources:read", (VIEW, "source_id")),
("/api/sources/<string:source_id>/wiki/page", "GET"): _rule("sources:read", (VIEW, "source_id")),
("/api/sources/<string:source_id>/wiki/settings", "GET"): _rule("sources:read", (VIEW, "source_id")),
("/api/sources/<string:source_id>/graph", "GET"): _rule("sources:read", (VIEW, "source_id")),
("/api/sources/<string:source_id>/graph/node/<string:node_id>", "GET"): _rule(
"sources:read", (VIEW, "source_id")
),
("/api/sources/<string:source_id>/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/<string:source_id>/config", "PATCH"): _rule("sources:write", (VIEW, "source_id")),
("/api/sources/<string:source_id>/wiki/page", "PUT"): _rule("sources:write", (VIEW, "source_id")),
("/api/sources/<string:source_id>/wiki/convert", "POST"): _rule("sources:write", (VIEW, "source_id")),
("/api/sources/<string:source_id>/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/<string:model_id>", "GET"): _rule("models:read"),
("/api/user/models", "POST"): _rule("models:write"),
("/api/user/models/<string:model_id>", "PATCH"): _rule("models:write"),
("/api/user/models/<string:model_id>", "DELETE"): _rule("models:write"),
("/api/user/models/test", "POST"): _rule("models:write"),
("/api/user/models/<string:model_id>/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/<string:workflow_id>", "GET"): _rule("workflows:read", (VIEW, "workflow_id")),
("/api/workflows/<string:workflow_id>", "PUT"): _rule(
"workflows:write", (VIEW, "workflow_id"), blocked_by=_WORKFLOW_CONTENT_FAMILIES
),
("/api/workflows/<string:workflow_id>", "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/<string:message_id>/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/<string:team_id>", "GET"): _rule("teams:read"),
("/api/teams/<string:team_id>/members", "GET"): _rule("teams:read"),
("/api/teams/<string:team_id>/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/<string:source_id>/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/<string:token_id>": ("*",),
"/api/user/tokens/<string:token_id>/regenerate": ("*",),
"/api/generate_token": ("*",),
"/api/combine": ("*",),
"/api/download": ("*",),
"/api/upload_index": ("*",),
"/api/share": ("*",),
"/api/shared_agent": ("*",),
"/api/shared_conversation/<string:identifier>": ("*",),
"/api/webhooks/agents/<string:webhook_token>": ("*",),
"/api/images/<string:agent_id>/<string:capability>": ("*",),
"/api/mcp_server/callback": ("*",),
"/api/mcp_server/auth_status": ("*",),
"/api/artifact/<artifact_id>": ("*",),
"/api/artifacts": ("*",),
"/api/artifacts/<artifact_id>": ("*",),
"/api/artifacts/<artifact_id>/restore": ("*",),
"/api/artifacts/<artifact_id>/versions/<int:version>": ("*",),
"/api/stt": ("*",),
"/api/stt/live/start": ("*",),
"/api/stt/live/chunk": ("*",),
"/api/stt/live/finish": ("*",),
"/api/tts": ("*",),
"/api/teams": ("POST",),
"/api/teams/<string:team_id>": ("PUT", "DELETE"),
"/api/teams/<string:team_id>/members": ("POST",),
"/api/teams/<string:team_id>/members/<string:member_id>": ("*",),
"/api/teams/<string:team_id>/grants": ("POST", "DELETE"),
"/api/teams/<string:team_id>/transfer_owner": ("*",),
"/api/resource_settings": ("PUT",),
# Who may edit a wiki from outside the app is the owner's call in a session.
"/api/sources/<string:source_id>/wiki/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 ""