mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 16:16:48 +00:00
feat(pat): enforce scopes and resource restrictions, deny by default
A central rule table maps each route and method to the scope a token needs; a route that is not listed cannot be called with a token, and a test fails when a registered route is left unclassified. Token management, admin, team management, sign-in, device pairing and OAuth handshakes are never token reachable, and a token never carries the admin role. A token restricted to specific agents, sources, prompts, tools or workflows is held to its allowlist: ids are checked wherever a route carries them, listings are filtered, creation is refused, and routes whose rows cannot be tied to the allowlist are closed. Agent import checks the resolved target.
This commit is contained in:
1 parent
3a74aa23f0
commit
82ec6cba0e
10 files changed
+1114
-4
No files matched your search
@@ -0,0 +1,447 @@
|
||||
"""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
|
||||
|
||||
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]]] = 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)
|
||||
|
||||
|
||||
# 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 _chat_check(request, resource_filter: dict) -> 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 token restricted on any family must name an allowed agent (or,
|
||||
when only sources are restricted, chat against allowed sources directly).
|
||||
An agent ``api_key`` in the body would swap in an arbitrary agent.
|
||||
"""
|
||||
body = _json_body(request)
|
||||
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 not agent_ids:
|
||||
return "This token is restricted to specific agents; pass agent_id"
|
||||
return None # the agent id itself is verified through ``refs``
|
||||
if agent_ids:
|
||||
return "This token is restricted to specific resources and cannot run arbitrary agents"
|
||||
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/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),
|
||||
("/api/update_agent/<string:agent_id>", "PUT"): _rule(
|
||||
"agents:write", (VIEW, "agent_id"), refs=_AGENT_BODY_REFS
|
||||
),
|
||||
("/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.
|
||||
("/api/agents/<string:agent_id>/schedules", "GET"): _rule(
|
||||
"schedules:read", refs=(("agents", (VIEW, "agent_id")),)
|
||||
),
|
||||
("/api/agents/<string:agent_id>/schedules", "POST"): _rule(
|
||||
"schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=("tools",)
|
||||
),
|
||||
("/api/schedules/<string:schedule_id>", "GET"): _rule("schedules:read", blocked_by=("agents",)),
|
||||
("/api/schedules/<string:schedule_id>/runs", "GET"): _rule("schedules:read", blocked_by=("agents",)),
|
||||
("/api/schedules/<string:schedule_id>/runs/<string:run_id>", "GET"): _rule(
|
||||
"schedules:read", blocked_by=("agents",)
|
||||
),
|
||||
("/api/schedules/<string:schedule_id>", "PUT"): _rule("schedules:write", blocked_by=("agents", "tools")),
|
||||
("/api/schedules/<string:schedule_id>", "PATCH"): _rule("schedules:write", blocked_by=("agents", "tools")),
|
||||
("/api/schedules/<string:schedule_id>", "DELETE"): _rule("schedules:write", blocked_by=("agents",)),
|
||||
("/api/schedules/<string:schedule_id>/run", "POST"): _rule("schedules:write", blocked_by=("agents",)),
|
||||
# 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>/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")
|
||||
),
|
||||
# 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
|
||||
("/api/workflows", "POST"): _rule("workflows:write"),
|
||||
("/api/workflows/<string:workflow_id>", "GET"): _rule("workflows:read", (VIEW, "workflow_id")),
|
||||
("/api/workflows/<string:workflow_id>", "PUT"): _rule("workflows:write", (VIEW, "workflow_id")),
|
||||
("/api/workflows/<string:workflow_id>", "DELETE"): _rule("workflows:write", (VIEW, "workflow_id")),
|
||||
# Conversations and analytics span every agent, so an agent-restricted token is kept out.
|
||||
("/api/get_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)),
|
||||
("/api/search_conversations", "GET"): _rule("conversations:read", blocked_by=("agents",)),
|
||||
("/api/get_single_conversation", "GET"): _rule("conversations:read", blocked_by=("agents",)),
|
||||
("/api/messages/<string:message_id>/tail", "GET"): _rule(
|
||||
any_of=("conversations:read", "chat:run"), family=None
|
||||
),
|
||||
("/api/delete_conversation", "POST"): _rule("conversations:write", blocked_by=("agents",)),
|
||||
("/api/delete_all_conversations", "GET"): _rule("conversations:write", blocked_by=("agents",)),
|
||||
("/api/update_conversation_name", "POST"): _rule("conversations:write", blocked_by=("agents",)),
|
||||
("/api/feedback", "POST"): _rule("conversations:write", blocked_by=("agents",)),
|
||||
("/api/get_message_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)),
|
||||
("/api/get_token_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)),
|
||||
("/api/get_feedback_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)),
|
||||
("/api/get_tool_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)),
|
||||
("/api/get_schedule_analytics", "POST"): _rule("analytics:read", blocked_by=("agents",)),
|
||||
("/api/get_user_logs", "POST"): _rule("analytics:read", blocked_by=("agents",)),
|
||||
# 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"),
|
||||
# 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/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": ("*",),
|
||||
"/swagger.json": ("*",),
|
||||
}
|
||||
DENIED_PREFIXES = (
|
||||
"/api/admin/",
|
||||
"/api/auth/oidc/",
|
||||
"/api/connectors/",
|
||||
"/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)
|
||||
rule = RULES.get((url_rule.rule, request.method)) if url_rule is not None else None
|
||||
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)
|
||||
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) -> 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)
|
||||
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) or {}
|
||||
if decoded.get("auth_method") != "pat":
|
||||
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
|
||||
@@ -36,6 +36,7 @@ from docsgpt.agents.default_tools import (
|
||||
synthesized_tool_name_for_id,
|
||||
)
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import allowed_ids
|
||||
from docsgpt.core.model_utils import validate_model_id
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.safe_url import UnsafeUserUrlError, validate_user_base_url
|
||||
@@ -1717,6 +1718,32 @@ def _read_import_payload(req):
|
||||
return raw.decode("utf-8", "replace"), {}
|
||||
|
||||
|
||||
def _restricted_token_denial(conn, user: str, doc: dict) -> Optional[str]:
|
||||
"""Why a resource-restricted personal access token may not import ``doc``, if it may not.
|
||||
|
||||
An import resolves sources, prompts and tools by name and may create them,
|
||||
so a token restricted on any of those families cannot be held to its
|
||||
allowlist here. A token restricted to specific agents may update exactly
|
||||
those; it can never create one.
|
||||
"""
|
||||
for family in ("sources", "prompts", "tools", "workflows"):
|
||||
if allowed_ids(request, family) is not None:
|
||||
return f"A token restricted to specific {family} cannot import agents"
|
||||
allowed_agents = allowed_ids(request, "agents")
|
||||
if allowed_agents is None:
|
||||
return None
|
||||
target = _resolve_target(conn, user, doc.get("metadata") or {})
|
||||
if target["action"] != "update" or target["agent_id"] not in allowed_agents:
|
||||
return "This token is restricted to specific agents and may only update those"
|
||||
return None
|
||||
|
||||
|
||||
def _token_denied_response(reason: str):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "resource_not_allowed", "message": reason}), 403
|
||||
)
|
||||
|
||||
|
||||
@agents_portability_ns.route("/export_agent")
|
||||
class ExportAgent(Resource):
|
||||
@api.doc(params={"id": "Agent ID"}, description="Export an agent as YAML")
|
||||
@@ -1763,6 +1790,8 @@ class ImportAgentPlan(Resource):
|
||||
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
if reason := _restricted_token_denial(conn, user, doc):
|
||||
return _token_denied_response(reason)
|
||||
plan = plan_import(conn, user, doc)
|
||||
except Exception:
|
||||
current_app.logger.error("Agent import plan failed", exc_info=True)
|
||||
@@ -1786,6 +1815,8 @@ class ImportAgent(Resource):
|
||||
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
if reason := _restricted_token_denial(conn, user, doc):
|
||||
return _token_denied_response(reason)
|
||||
result = apply_import(conn, user, doc, resolution)
|
||||
except AgentImportError as exc:
|
||||
# Apply-time rejection of the user's document (e.g. the workflow
|
||||
|
||||
@@ -9,6 +9,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
from docsgpt.api.user.base import (
|
||||
copy_agent_image_for_user,
|
||||
@@ -498,7 +499,7 @@ class GetAgents(Resource):
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(jsonify(list_agents), 200)
|
||||
return make_response(jsonify(filter_listing(request, "agents", list_agents)), 200)
|
||||
|
||||
|
||||
@agents_ns.route("/create_agent")
|
||||
|
||||
@@ -5,6 +5,7 @@ from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.team_sharing import team_access_for, visible_with_access
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.prompts.composer import compose_preset, is_composed_preset
|
||||
@@ -91,7 +92,8 @@ class GetPrompts(Resource):
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving prompts: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(jsonify(list_prompts), 200)
|
||||
# Presets (default/creative/strict) have no row id and stay visible to a restricted token.
|
||||
return make_response(jsonify(filter_listing(request, "prompts", list_prompts)), 200)
|
||||
|
||||
|
||||
@prompts_ns.route("/get_single_prompt")
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import ValidationError
|
||||
|
||||
from docsgpt.agents.tools.path_utils import validate_tool_path
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.tasks import (
|
||||
convert_source_to_wiki,
|
||||
extract_graph,
|
||||
@@ -130,7 +131,7 @@ class CombinedJson(Resource):
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving sources: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(jsonify(data), 200)
|
||||
return make_response(jsonify(filter_listing(request, "sources", data)), 200)
|
||||
|
||||
|
||||
@sources_ns.route("/sources/paginated")
|
||||
|
||||
@@ -16,6 +16,7 @@ from docsgpt.agents.default_tools import (
|
||||
from docsgpt.agents.tools.spec_parser import parse_spec
|
||||
from docsgpt.agents.tools.tool_manager import ToolManager
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
@@ -265,6 +266,9 @@ class GetTools(Resource):
|
||||
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
|
||||
shaped["team_access"] = team_shared.get(str(row["id"]))
|
||||
user_tools.append(shaped)
|
||||
# A resource-restricted token sees only its allowed tools; the
|
||||
# default and builtin rows appended below belong to no user.
|
||||
user_tools = filter_listing(request, "tools", user_tools)
|
||||
|
||||
# ``scheduler`` is dual-registered (default chat tool + agent-
|
||||
# selectable builtin) and resolves to the same synthetic uuid5 id.
|
||||
|
||||
+18
-1
@@ -22,8 +22,11 @@ from docsgpt.api.devices import devices_bp # noqa: E402
|
||||
from docsgpt.api.internal.routes import internal # noqa: E402
|
||||
from docsgpt.api.oidc import oidc_bp # noqa: E402
|
||||
from docsgpt.api.oidc.denylist import is_denied as oidc_session_denied # noqa: E402
|
||||
from docsgpt.api.pat.routes import pat_ns # noqa: E402
|
||||
from docsgpt.api.pat.rules import authorize as authorize_pat # noqa: E402
|
||||
from docsgpt.api.pat.tokens import is_pat # noqa: E402
|
||||
from docsgpt.api.scim import scim_bp # noqa: E402
|
||||
from docsgpt.api.user.authz import resolve_roles # noqa: E402
|
||||
from docsgpt.api.user.authz import ROLE_USER, resolve_roles # noqa: E402
|
||||
from docsgpt.api.user.routes import user # noqa: E402
|
||||
from docsgpt.api.connector.routes import connector # noqa: E402
|
||||
from docsgpt.api.v1 import v1_bp # noqa: E402
|
||||
@@ -109,6 +112,8 @@ app.register_blueprint(v1_bp)
|
||||
# first app and raise "add_url_rule can no longer be called".
|
||||
if admin_ns not in api.namespaces:
|
||||
api.add_namespace(admin_ns)
|
||||
if pat_ns not in api.namespaces:
|
||||
api.add_namespace(pat_ns)
|
||||
app.config.update(
|
||||
UPLOAD_FOLDER="inputs",
|
||||
CELERY_BROKER_URL=settings.CELERY_BROKER_URL,
|
||||
@@ -317,6 +322,18 @@ def authenticate_request():
|
||||
request.decoded_token = None
|
||||
elif "error" in decoded_token:
|
||||
return jsonify(decoded_token), 401
|
||||
elif is_pat(decoded_token):
|
||||
# Scopes and resource restrictions are enforced here, centrally and
|
||||
# deny by default (docsgpt/api/pat/rules.py). A token never carries
|
||||
# admin, whatever its owner holds, and the session denylist does not
|
||||
# apply: the token lookup already excludes revoked tokens and
|
||||
# deactivated users.
|
||||
denied = authorize_pat(request, decoded_token)
|
||||
if denied is not None:
|
||||
body, status = denied
|
||||
return jsonify(body), status
|
||||
decoded_token["roles"] = [ROLE_USER]
|
||||
request.decoded_token = decoded_token
|
||||
elif settings.AUTH_TYPE == "oidc" and oidc_session_denied(decoded_token):
|
||||
# Back-channel logout / SCIM deactivation revoked this session.
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user