mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 12:13:38 +00:00
Roles audit and revamp
This commit is contained in:
1 parent
208d34abc7
commit
98afa5d93c
134 files changed
+13725
-1702
No files matched your search
@@ -369,6 +369,11 @@ def resolve_tool_by_id(
|
||||
|
||||
Dual-registered tools (e.g. ``scheduler``) get both flags on the resolved
|
||||
row so callers can branch on either path without losing the discriminator.
|
||||
|
||||
A ``user_tools`` row resolves when ``user`` owns it or a team grant gives
|
||||
``user`` the ``use_in_own`` action on it (checked live, so a revoked grant
|
||||
drops the tool on the next run). The returned row is the owner's, so its
|
||||
``user_id`` is the credential owner.
|
||||
"""
|
||||
default_name = default_tool_name_for_id(tool_id)
|
||||
builtin_name = builtin_agent_tool_name_for_id(tool_id)
|
||||
@@ -382,4 +387,24 @@ def resolve_tool_by_id(
|
||||
return synthesize_builtin_agent_tool(builtin_name)
|
||||
if user_tools_repo is None or not user:
|
||||
return None
|
||||
return user_tools_repo.get_any(str(tool_id), user)
|
||||
row = user_tools_repo.get_any(str(tool_id), user)
|
||||
if row is not None:
|
||||
return row
|
||||
return _resolve_shared_tool(str(tool_id), user, user_tools_repo)
|
||||
|
||||
|
||||
def _resolve_shared_tool(tool_id: str, user: str, user_tools_repo: Any) -> Optional[Dict[str, Any]]:
|
||||
"""The owner's row for a team-shared tool ``user`` may use in their own agents."""
|
||||
conn = getattr(user_tools_repo, "_conn", None)
|
||||
if conn is None:
|
||||
return None
|
||||
# Lazy: resource_access lives under docsgpt.api, whose package import
|
||||
# pulls in every route module (and those import this module).
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
ra = resolve(conn, "tool", tool_id, user)
|
||||
if ra is None or ra.access == "owner" or not ra.can("use_in_own"):
|
||||
if ra is not None:
|
||||
logger.info("shared tool %s not usable by %s (access=%s); dropped", tool_id, user, ra.access)
|
||||
return None
|
||||
return user_tools_repo.get_any(ra.resource_id, ra.owner_id)
|
||||
@@ -14,7 +14,10 @@ from docsgpt.api.answer.services.prompt_renderer import (
|
||||
prompt_embeds_documents,
|
||||
resolve_prompt_skeleton,
|
||||
)
|
||||
from docsgpt.api.answer.services.stream_processor import get_prompt
|
||||
from docsgpt.api.answer.services.stream_processor import (
|
||||
authorized_prompt_id,
|
||||
get_prompt,
|
||||
)
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.quotas.service import QuotaExceededError, QuotaService
|
||||
from docsgpt.retriever.retriever_creator import RetrieverCreator
|
||||
@@ -156,7 +159,9 @@ def _run_agent_headless(
|
||||
# ``chunks=0`` switches retrieval off; only a missing value takes the default.
|
||||
raw_chunks = agent_config.get("chunks")
|
||||
chunks = 6 if raw_chunks in (None, "") else int(raw_chunks)
|
||||
prompt_id = agent_config.get("prompt_id", "default")
|
||||
# Runs as the owner: a prompt they can no longer use (revoked grant,
|
||||
# deleted) falls back to the default instead of rendering anyway.
|
||||
prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner)
|
||||
user_api_key = agent_config.get("key")
|
||||
agent_id = _resolve_agent_id(agent_config)
|
||||
agent_type = agent_config.get("agent_type", "classic")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@@ -29,6 +30,45 @@ from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: ``user_tools.config`` key holding an api_tool's encrypted header /
|
||||
#: query-param values: ``{action: {section: {param: value}}}``, keyed by the
|
||||
#: tool owner's id. The plaintext ``value`` of those entries is kept empty.
|
||||
API_TOOL_SECRETS_KEY = "encrypted_action_secrets"
|
||||
API_TOOL_SECRET_SECTIONS = ("headers", "query_params")
|
||||
|
||||
|
||||
def api_tool_action_with_secrets(tool_data: Dict, action_name: str, fallback_owner: Optional[str] = None) -> Dict:
|
||||
"""An api_tool action definition with its stored secret values merged back.
|
||||
|
||||
Secrets are decrypted with the tool row's ``user_id`` (the owner), never
|
||||
the invoker's id. Legacy rows that still hold plaintext values need no
|
||||
merge and are returned as stored.
|
||||
|
||||
Args:
|
||||
tool_data: The ``user_tools`` row.
|
||||
action_name: The action key in ``config["actions"]``.
|
||||
fallback_owner: Used only when the row carries no ``user_id``.
|
||||
|
||||
Returns:
|
||||
A deep copy of the action with header / query-param values filled in.
|
||||
"""
|
||||
config = tool_data.get("config") or {}
|
||||
action = copy.deepcopy(config["actions"][action_name])
|
||||
blob = config.get(API_TOOL_SECRETS_KEY)
|
||||
owner = tool_data.get("user_id") or fallback_owner
|
||||
if not blob or not owner:
|
||||
return action
|
||||
secrets = decrypt_credentials(blob, owner).get(action_name) or {}
|
||||
for section in API_TOOL_SECRET_SECTIONS:
|
||||
block = action.get(section)
|
||||
props = block.get("properties") if isinstance(block, dict) else None
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
for param, value in (secrets.get(section) or {}).items():
|
||||
if isinstance(props.get(param), dict):
|
||||
props[param]["value"] = value
|
||||
return action
|
||||
|
||||
|
||||
def record_tool_span_start(call: Any, **attributes: Any) -> Any:
|
||||
"""Open an ``execute_tool`` span for ``call`` (no-op without an active trace)."""
|
||||
@@ -565,6 +605,7 @@ class ToolExecutor:
|
||||
"""Resolve an agentless chat's toolset: explicit user tools plus defaults."""
|
||||
with db_readonly() as conn:
|
||||
user_tools = UserToolsRepository(conn).list_active_for_user(user)
|
||||
user_tools.extend(self._shared_in_chat_tools(conn, user))
|
||||
user_doc = UsersRepository(conn).get(user) if self.agent_id is None else None
|
||||
# Headless agentless runs (e.g. scheduled fire) drop chat-only
|
||||
# tools (``scheduler``) from explicit user_tools too.
|
||||
@@ -581,6 +622,32 @@ class ToolExecutor:
|
||||
tools[str(default_row["id"])] = default_row
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
def _shared_in_chat_tools(conn, user: str) -> List[Dict]:
|
||||
"""Team-shared tools the user switched into their chats and may still use.
|
||||
|
||||
The grant (and the ``use_in_own`` switch) is re-checked on every call;
|
||||
a revoked tool is dropped silently. Rows are the owner's, so their
|
||||
credentials decrypt with the owner's id.
|
||||
"""
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
|
||||
tool_ids = UserToolPreferencesRepository(conn).list_in_chat_tool_ids(user)
|
||||
if not tool_ids:
|
||||
return []
|
||||
repo = UserToolsRepository(conn)
|
||||
rows: List[Dict] = []
|
||||
for tid in tool_ids:
|
||||
row = resolve_tool_by_id(tid, user, user_tools_repo=repo)
|
||||
if row is None or row.get("user_id") == user:
|
||||
if row is None:
|
||||
logger.info("in-chat shared tool %s no longer usable by %s; dropped", tid, user)
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def merge_client_tools(self, tools_dict: Dict, client_tools: List[Dict]) -> Dict:
|
||||
"""Merge client-provided tool definitions into tools_dict.
|
||||
|
||||
@@ -1261,7 +1328,7 @@ class ToolExecutor:
|
||||
return error_message, call_id
|
||||
yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}}
|
||||
action_data = (
|
||||
tool_data["config"]["actions"][action_name]
|
||||
api_tool_action_with_secrets(tool_data, action_name, self.user)
|
||||
if tool_data["name"] == "api_tool"
|
||||
else next(action for action in tool_data["actions"] if action["name"] == action_name)
|
||||
)
|
||||
@@ -1528,10 +1595,13 @@ class ToolExecutor:
|
||||
if tool_data["name"] == "mcp_tool":
|
||||
tool_config["query_mode"] = True
|
||||
|
||||
# MCP OAuth tokens are looked up by user id: a shared server runs on
|
||||
# the owner's connection, like every other credential.
|
||||
load_user = (tool_data.get("user_id") or self.user) if tool_data["name"] == "mcp_tool" else self.user
|
||||
tool = tm.load_tool(
|
||||
tool_data["name"],
|
||||
tool_config=tool_config,
|
||||
user_id=self.user,
|
||||
user_id=load_user,
|
||||
)
|
||||
|
||||
# Don't cache api_tool since config varies by action
|
||||
|
||||
@@ -19,6 +19,8 @@ WIKI_UPDATED_VIA_AGENT = "agent"
|
||||
|
||||
MAX_WIKI_PAGE_BYTES = 1_000_000
|
||||
|
||||
_WRITE_ACTIONS = frozenset({"create", "str_replace", "insert", "delete", "rename"})
|
||||
|
||||
|
||||
class WikiTool(Tool):
|
||||
"""Wiki
|
||||
@@ -49,6 +51,11 @@ class WikiTool(Tool):
|
||||
if not self.source_id:
|
||||
return "Error: WikiTool requires a source_id."
|
||||
|
||||
if action_name in _WRITE_ACTIONS:
|
||||
denied = self._write_denied()
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
if action_name == "view":
|
||||
return self._view(kwargs.get("path", "/"), kwargs.get("view_range"))
|
||||
if action_name == "create":
|
||||
@@ -192,6 +199,37 @@ class WikiTool(Tool):
|
||||
},
|
||||
]
|
||||
|
||||
def _write_denied(self) -> Optional[str]:
|
||||
"""Re-check the caller's live ``edit`` right before a write.
|
||||
|
||||
The tool is attached for a writable source when the conversation
|
||||
starts, but a grant can be revoked (or downgraded to viewer) mid-run.
|
||||
Resolving access on every write stops the agent from editing once the
|
||||
caller no longer may. Fails closed on an unknown caller or a failed
|
||||
lookup. Re-embeds still run as the owner.
|
||||
|
||||
Returns:
|
||||
Optional[str]: An error message for the LLM, or None when allowed.
|
||||
"""
|
||||
from docsgpt.api.user import resource_access
|
||||
|
||||
message = "Error: You no longer have edit access to this wiki, so it can't be changed."
|
||||
if not self.updated_by:
|
||||
return message
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
access = resource_access.resolve(
|
||||
conn, "source", str(self.source_id), self.updated_by
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Wiki write access check failed for source %s", self.source_id
|
||||
)
|
||||
return message
|
||||
if access is None or not access.can("edit"):
|
||||
return message
|
||||
return None
|
||||
|
||||
def get_config_requirements(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user