fix(pat): close message replay to restricted tokens, filter builtin tools, reject non-object bodies

Message tail and the ASGI reconnect stream cannot tie a message to an
allowlist, so any token with a resource filter is refused there. The tools
listing now applies the allowlist to default and builtin rows as well. Token
creation answers 400 instead of 500 for a JSON body that is not an object.
This commit is contained in:
arc53-machine committed 2026-09-19 23:54:35 +01:00
1 parent 17d66c061d
commit 934ae1afb3
8 files changed
+45 -7

No files matched your search

+13 -1
View File
@@ -31,7 +31,8 @@ async def authenticate(
request: The incoming Starlette request.
pat_scope: Scope a personal access token needs for this route. Left
unset, the route rejects PATs outright (deny by default, matching
the Flask rule table in ``docsgpt/api/pat/rules.py``).
the Flask rule table in ``docsgpt/api/pat/rules.py``). A token with
a resource filter is always rejected.
Returns:
tuple: ``(claims, None)`` for an authenticated caller, ``(None, None)``
@@ -52,6 +53,17 @@ async def authenticate(
{"success": False, "message": "Token lacks the required scope", "error": "insufficient_scope"},
status_code=403,
)
if decoded.get("resource_filter"):
# These routes sit outside the Flask rule table and cannot tie what
# they serve to an allowlist, so a restricted token is kept out.
return None, JSONResponse(
{
"success": False,
"message": "This endpoint is not available to a resource-restricted token",
"error": "resource_not_allowed",
},
status_code=403,
)
return decoded, None
# The denylist is a sync Redis read; keep it off the event loop.
if settings.AUTH_TYPE == "oidc" and await anyio.to_thread.run_sync(oidc_session_denied, decoded):
+5 -1
View File
@@ -113,7 +113,11 @@ class PersonalAccessTokens(Resource):
if not auth_type_supports_pats():
return _error("Personal access tokens are not available on this server", 403)
body = request.get_json(silent=True) or {}
body = request.get_json(silent=True)
if body is None:
body = {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
name = body.get("name")
if not isinstance(name, str) or not name.strip():
return _error("name is required", 400)
+4 -1
View File
@@ -53,6 +53,8 @@ def _rule(scope: Optional[str] = None, *ids: Locator, any_of: tuple[str, ...] =
return Rule(scopes=scopes, family=family, ids=tuple(ids), **kwargs)
_ALL_FAMILIES = ("agents", "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")),
@@ -216,8 +218,9 @@ RULES: dict[tuple[str, str], Rule] = {
("/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",)),
# 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=("conversations:read", "chat:run"), family=None
any_of=("conversations:read", "chat:run"), family=None, blocked_by=_ALL_FAMILIES
),
("/api/delete_conversation", "POST"): _rule("conversations:write", blocked_by=("agents",)),
("/api/delete_all_conversations", "GET"): _rule("conversations:write", blocked_by=("agents",)),
+3 -3
View File
@@ -266,9 +266,6 @@ 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.
@@ -298,6 +295,9 @@ class GetTools(Resource):
builtin_copy.get("name") in WORKFLOW_ONLY_BUILTINS
)
user_tools.append(builtin_copy)
# A resource-restricted token sees only its allowed tools. Default
# and builtin rows have ids too, so they follow the same allowlist.
user_tools = filter_listing(request, "tools", user_tools)
except Exception as err:
current_app.logger.error(f"Error getting user tools: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)