Roles audit and revamp

This commit is contained in:
Pavel committed 2026-09-29 10:42:39 +04:00
1 parent 208d34abc7
commit 98afa5d93c
134 files changed
+13725 -1702

No files matched your search

+26 -1
View File
@@ -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)
+7 -2
View File
@@ -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")
+72 -2
View File
@@ -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
+38
View File
@@ -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 {}