mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 20:14:16 +00:00
Gate outside writes on the owner's stored credentials, not only connections
API-key, widget and public-link callers were held to the write allowlist only on connected accounts, so they could still write through an API tool or a signed-in MCP server that carries the owner's credentials. Any write on credentials the caller doesn't hold is now refused unless the owner allowlisted it, and a scheduled run for such a caller counts as not holding any. The tool list names these writes per tool, so the allowlist can offer them, and its copy now names every route it covers.
This commit is contained in:
1 parent
656e3abbd0
commit
5c8b97b609
14 files changed
+243
-36
No files matched your search
@@ -1157,20 +1157,24 @@ class ToolExecutor:
|
||||
|
||||
require_approval = action_access(tool_data.get("name"), action_data) == ACCESS_WRITE
|
||||
|
||||
# An API-key caller writes on the owner's account only with the
|
||||
# An API-key caller writes with the owner's credentials only with the
|
||||
# owner's say-so: nobody can approve in a widget, and "Always allow"
|
||||
# was the owner's choice for themselves, not for anyone with the key.
|
||||
# A public-link user is a stranger to the owner, so their approval
|
||||
# can't stand in for the owner's either.
|
||||
public_on_owner_account = self.public_link_caller and resolved is not None and resolved.delegated
|
||||
if (self.external_caller and resolved is not None) or public_on_owner_account:
|
||||
if (self.external_caller or self.public_link_caller) and self._on_owner_credentials(tool_data, resolved):
|
||||
from docsgpt.connectors.permissions import ACCESS_WRITE, action_access
|
||||
|
||||
if action_access(tool_data.get("name"), action_data) == ACCESS_WRITE:
|
||||
entry = f"{tool_data.get('id') or tool_id}:{action_name}"
|
||||
if entry in self.api_write_allowlist:
|
||||
return None
|
||||
route = "its API key" if self.external_caller else "its public link"
|
||||
route = "for API, widget or webhook callers" if self.external_caller else "from its public link"
|
||||
target = (
|
||||
f"the owner's {resolved.connector_name} account"
|
||||
if resolved is not None and resolved.connector_name
|
||||
else "the owner's credentials"
|
||||
)
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"name": llm_name,
|
||||
@@ -1181,8 +1185,8 @@ class ToolExecutor:
|
||||
"arguments": arguments,
|
||||
"pause_type": "headless_denied",
|
||||
"deny_reason": (
|
||||
f"This agent can't take this action with {resolved.connector_name or 'the owner'}'s "
|
||||
f"account through {route}. The owner can allow it in the agent's Access details."
|
||||
f"This agent can't take this action with {target} {route}. "
|
||||
"The owner can allow it in the agent's Access details."
|
||||
),
|
||||
"error_type": "tool_not_allowed",
|
||||
"thought_signature": getattr(call, "thought_signature", None),
|
||||
@@ -1238,6 +1242,33 @@ class ToolExecutor:
|
||||
|
||||
return None
|
||||
|
||||
def _on_owner_credentials(self, tool_data: Dict, resolved) -> bool:
|
||||
"""Whether a call would act with credentials the caller doesn't hold.
|
||||
|
||||
The connection's account when there is one, else the tool owner's
|
||||
stored credentials (see ``holds_owner_credentials``). An API-key
|
||||
caller and any scheduled run hold none of their own: the run acts as
|
||||
the owner. A public-link user in the app holds their own account only.
|
||||
|
||||
Args:
|
||||
tool_data: The ``user_tools`` row being called.
|
||||
resolved: The connection ``resolve_connection`` picked, or None.
|
||||
|
||||
Returns:
|
||||
True when the call would use someone else's credentials.
|
||||
"""
|
||||
from docsgpt.connectors.permissions import holds_owner_credentials
|
||||
|
||||
if resolved is not None:
|
||||
holder = (resolved.row or {}).get("user_id")
|
||||
elif holds_owner_credentials(tool_data):
|
||||
holder = tool_data.get("user_id")
|
||||
else:
|
||||
return False
|
||||
if self.external_caller or self.headless:
|
||||
return True
|
||||
return holder != self.user
|
||||
|
||||
def _remote_device_requires_approval(
|
||||
self,
|
||||
tool_data: Dict,
|
||||
|
||||
@@ -36,6 +36,7 @@ from docsgpt.api.user.resource_access import (
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.connectors.catalog import base_url, definition_for_tool
|
||||
from docsgpt.connectors.service import account_tool_names
|
||||
from docsgpt.connectors.permissions import owner_credential_writes
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import CredentialDecryptionError, decrypt_credentials, encrypt_credentials
|
||||
@@ -709,6 +710,9 @@ class GetTools(Resource):
|
||||
|
||||
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
|
||||
tool_copy = _row_to_api(row)
|
||||
# The writes an agent's API write allowlist can cover (read
|
||||
# from the stored row, before any secret is masked).
|
||||
tool_copy["owner_credential_writes"] = owner_credential_writes(row)
|
||||
config_req = tool_copy.get("configRequirements", {})
|
||||
if not config_req:
|
||||
tool_instance = tool_manager.tools.get(tool_copy.get("name"))
|
||||
|
||||
@@ -101,3 +101,58 @@ def apply_default_permissions(tool_name: Optional[str], actions: list[dict]) ->
|
||||
updated["require_approval"] = True
|
||||
stamped.append(updated)
|
||||
return stamped
|
||||
|
||||
|
||||
def tool_actions(tool: dict) -> list[dict]:
|
||||
"""A tool row's actions, each carrying its ``name``.
|
||||
|
||||
An API tool keeps its actions under ``config["actions"]`` keyed by name;
|
||||
every other tool lists them in ``actions``.
|
||||
"""
|
||||
if tool.get("name") == "api_tool":
|
||||
stored = (tool.get("config") or {}).get("actions") or {}
|
||||
return [{**(action or {}), "name": name} for name, action in stored.items()]
|
||||
return [action for action in tool.get("actions") or [] if isinstance(action, dict)]
|
||||
|
||||
|
||||
def holds_owner_credentials(tool: dict) -> bool:
|
||||
"""Whether ``tool`` acts with credentials stored by its owner.
|
||||
|
||||
A connection's account, an API tool (its headers and query values carry
|
||||
the owner's keys), a stored secret, or an MCP server the owner signed in
|
||||
to. Anyone running it acts as the owner there, whoever they are.
|
||||
|
||||
Args:
|
||||
tool: A ``user_tools`` row.
|
||||
|
||||
Returns:
|
||||
True when the tool runs on the owner's credentials.
|
||||
"""
|
||||
if tool.get("connection_id") or tool.get("name") == "api_tool":
|
||||
return True
|
||||
config = tool.get("config") or {}
|
||||
if config.get("encrypted_credentials"):
|
||||
return True
|
||||
return tool.get("name") == "mcp_tool" and (config.get("auth_type") or "none") != "none"
|
||||
|
||||
|
||||
def owner_credential_writes(tool: dict) -> list[str]:
|
||||
"""Names of the tool's write actions that run on its owner's credentials.
|
||||
|
||||
These are what someone who can't approve for the owner (an API-key or
|
||||
widget caller, a public-link user, a webhook) may run only when the owner
|
||||
allows them in the agent's API write allowlist.
|
||||
|
||||
Args:
|
||||
tool: A ``user_tools`` row.
|
||||
|
||||
Returns:
|
||||
Action names, empty when the tool holds no owner credentials.
|
||||
"""
|
||||
if not holds_owner_credentials(tool):
|
||||
return []
|
||||
return [
|
||||
action["name"] for action in tool_actions(tool)
|
||||
if action.get("name") and action.get("active") is not False
|
||||
and action_access(tool.get("name"), action) == ACCESS_WRITE
|
||||
]
|
||||
Reference in new issue
Block a user