mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 04:17:56 +00:00
Merge main (roles and access revamp) into connectors
Main's access model (team editors and viewers, edit_credentials, owner-only OAuth servers, per-user tool preferences, resource sponsors) now applies to connection-backed tools and sources. Our migrations are renumbered to 0040_connections and 0041_connection_account_name, after main's 0038_resource_access_settings and 0039_resource_sponsors. Where the two sides met: MCP tools save and load their connections as the tool owner, editors may add a key (a new connection on the owner's account) but never rewrite an existing connection's secret, fixed values stay owner-only, and a member's own connection is used in member mode.
This commit is contained in:
commit
a1789d2ab4
167 files changed
+16909
-2126
No files matched your search
@@ -35,6 +35,24 @@ def looks_like_uuid(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(_UUID_RE.match(value))
|
||||
|
||||
|
||||
def canonical_uuid(value: Any) -> Any:
|
||||
"""The lowercase canonical form of a UUID string; anything else unchanged.
|
||||
|
||||
Postgres accepts any casing on ``CAST(... AS uuid)`` but returns the
|
||||
lowercase form, so an id used as a dict key or stored in a JSON/array
|
||||
column must be canonical to match what the database hands back.
|
||||
|
||||
Args:
|
||||
value: A candidate id.
|
||||
|
||||
Returns:
|
||||
``str(UUID(value))`` for a UUID, else ``value`` as given.
|
||||
"""
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
return str(UUID(value)) if looks_like_uuid(value) else value
|
||||
|
||||
|
||||
def row_to_dict(row: Any) -> dict:
|
||||
"""Convert a SQLAlchemy ``Row`` to a plain JSON-safe dict.
|
||||
|
||||
|
||||
@@ -187,6 +187,23 @@ Index(
|
||||
team_resource_grants_table.c.resource_id,
|
||||
)
|
||||
|
||||
# Per-asset sharing switches set by the owner (migration 0038). A missing row
|
||||
# means every switch is at its default; keys are validated in
|
||||
# ``docsgpt/api/user/resource_access.py``.
|
||||
resource_share_settings_table = Table(
|
||||
"resource_share_settings",
|
||||
metadata,
|
||||
Column("resource_type", Text, primary_key=True),
|
||||
Column("resource_id", UUID(as_uuid=True), primary_key=True),
|
||||
Column("settings", JSONB, nullable=False, server_default="{}"),
|
||||
Column("updated_by", Text),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
CheckConstraint(
|
||||
"resource_type IN ('agent', 'source', 'prompt', 'tool')",
|
||||
name="resource_share_settings_type_check",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
prompts_table = Table(
|
||||
"prompts",
|
||||
@@ -223,6 +240,22 @@ user_tools_table = Table(
|
||||
Column("credential_mode", Text, nullable=False, server_default="owner"),
|
||||
)
|
||||
|
||||
# A grantee's personal "In my chats" switch for a tool shared with them
|
||||
# (migration 0038). The owner's own switch stays ``user_tools.status``.
|
||||
user_tool_preferences_table = Table(
|
||||
"user_tool_preferences",
|
||||
metadata,
|
||||
Column("user_id", Text, primary_key=True),
|
||||
Column(
|
||||
"tool_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("user_tools.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column("in_chat", Boolean, nullable=False, server_default="false"),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
token_usage_table = Table(
|
||||
"token_usage",
|
||||
metadata,
|
||||
@@ -377,6 +410,9 @@ agents_table = Table(
|
||||
# Per-agent behavior contract (AgentConfig — guardrails today). Empty
|
||||
# ``{}`` parses to guardrails-disabled.
|
||||
Column("config", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
# ``"<type>:<id>" -> user_id`` of the editor vouching for a referenced
|
||||
# resource the owner can't use (migration 0039, see resource_access.py).
|
||||
Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
Column("default_model_id", Text),
|
||||
Column("folder_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")),
|
||||
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL")),
|
||||
@@ -595,7 +631,7 @@ connector_sessions_table = Table(
|
||||
Column("expires_at", DateTime(timezone=True)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
# Added in ``0038_connections``: each row is a connection (one signed-in
|
||||
# Added in ``0040_connections``: each row is a connection (one signed-in
|
||||
# account, one MCP server or one set of API credentials).
|
||||
Column("connector_key", Text),
|
||||
Column("display_name", Text),
|
||||
@@ -608,7 +644,7 @@ connector_sessions_table = Table(
|
||||
Column("scopes", JSONB, nullable=False, server_default="[]"),
|
||||
Column("last_error", Text),
|
||||
Column("last_used_at", DateTime(timezone=True)),
|
||||
# Added in ``0039_connection_account_name``: what the user calls the
|
||||
# Added in ``0041_connection_account_name``: what the user calls the
|
||||
# account; ``account_label`` stays its identity.
|
||||
Column("account_name", Text),
|
||||
)
|
||||
@@ -963,6 +999,8 @@ workflows_table = Table(
|
||||
Column("name", Text, nullable=False),
|
||||
Column("description", Text),
|
||||
Column("current_graph_version", Integer, nullable=False, server_default="1"),
|
||||
# Same shape as ``agents.resource_sponsors``, for node tools and sources.
|
||||
Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
|
||||
@@ -43,14 +43,14 @@ class AgentsRepository:
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
"allow_system_prompt_override",
|
||||
"shared", "shared_token", "shared_metadata",
|
||||
"tools", "json_schema", "models", "config", "legacy_mongo_id",
|
||||
"tools", "json_schema", "models", "config", "resource_sponsors", "legacy_mongo_id",
|
||||
"created_at", "updated_at", "last_used_at",
|
||||
}
|
||||
|
||||
for col, val in kwargs.items():
|
||||
if col not in _ALLOWED or val is None:
|
||||
continue
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
|
||||
# JSONB columns: pass the Python object directly. SQLAlchemy
|
||||
# Core's JSONB type processor json.dumps it once during
|
||||
# bind; pre-serialising would double-encode and the value
|
||||
@@ -234,7 +234,7 @@ class AgentsRepository:
|
||||
allowed = {
|
||||
"name", "description", "agent_type", "status", "key", "slug", "source_id",
|
||||
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
|
||||
"config",
|
||||
"config", "resource_sponsors",
|
||||
"default_model_id", "folder_id", "workflow_id",
|
||||
"extra_source_ids", "image",
|
||||
"limited_token_mode", "token_limit",
|
||||
@@ -248,7 +248,7 @@ class AgentsRepository:
|
||||
return False
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
|
||||
values[col] = val
|
||||
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
|
||||
values[col] = str(val) if val else None
|
||||
@@ -294,7 +294,7 @@ class AgentsRepository:
|
||||
allowed = {
|
||||
"name", "description", "agent_type", "status", "key", "slug", "source_id",
|
||||
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
|
||||
"config",
|
||||
"config", "resource_sponsors",
|
||||
"default_model_id", "folder_id", "workflow_id",
|
||||
"extra_source_ids", "image",
|
||||
"limited_token_mode", "token_limit",
|
||||
@@ -309,7 +309,7 @@ class AgentsRepository:
|
||||
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
|
||||
# See note in create(): JSONB columns receive Python
|
||||
# objects, the type processor handles serialisation.
|
||||
values[col] = val
|
||||
|
||||
@@ -15,7 +15,7 @@ Shape notes:
|
||||
* Every secret lives in ``encrypted_credentials``, written and read only by
|
||||
``docsgpt.connectors.service``; ``token_info`` and the ``tokens`` /
|
||||
``client_info`` keys of ``session_data`` are legacy plaintext that
|
||||
migration 0038 moved into it.
|
||||
migration 0040 moved into it.
|
||||
* ``session_data`` remains a catch-all JSONB for driver-specific state
|
||||
(tokens that don't fit anywhere else, per-provider scratch data).
|
||||
Promoted columns (``session_token``, ``user_email``, ``status``,
|
||||
|
||||
@@ -107,8 +107,9 @@ class TeamResourceGrantsRepository:
|
||||
that one member (the caller must have validated they're a team member).
|
||||
``ON CONFLICT`` on the functional dedup index makes re-sharing
|
||||
last-write-wins on ``access_level``. The caller MUST have verified
|
||||
``granted_by`` owns the resource (dispatched by ``resource_type``) — the
|
||||
polymorphic table has no FK to catch a type/id mismatch.
|
||||
``granted_by`` holds ``share`` on the resource (``resource_access.require``,
|
||||
dispatched by ``resource_type``) and pass the real owner as ``owner_id`` —
|
||||
the polymorphic table has no FK to catch a type/id mismatch.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Repository for the ``user_tool_preferences`` table.
|
||||
|
||||
A grantee's personal "In my chats" switch for a tool shared with them. The
|
||||
owner's own switch stays in ``user_tools.status``; a missing row here means
|
||||
off, so sharing a tool never adds it to anyone's chats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
|
||||
|
||||
class UserToolPreferencesRepository:
|
||||
"""Per-user, per-tool chat preferences for shared tools."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def set_in_chat(self, user_id: str, tool_id: str, in_chat: bool) -> None:
|
||||
"""Upsert the caller's "In my chats" switch for one tool.
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
tool_id: The shared tool's UUID.
|
||||
in_chat: Whether the tool joins the grantee's agentless chats.
|
||||
"""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_tool_preferences (user_id, tool_id, in_chat)
|
||||
VALUES (:user_id, CAST(:tool_id AS uuid), :in_chat)
|
||||
ON CONFLICT (user_id, tool_id)
|
||||
DO UPDATE SET in_chat = EXCLUDED.in_chat, updated_at = now()
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "tool_id": str(tool_id), "in_chat": bool(in_chat)},
|
||||
)
|
||||
|
||||
def in_chat_many(self, user_id: str, tool_ids: Iterable[str]) -> dict[str, bool]:
|
||||
"""``tool_id -> in_chat`` for the given tools; missing rows are False.
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
tool_ids: Tool ids to look up (non-UUIDs are ignored).
|
||||
|
||||
Returns:
|
||||
A dict with one entry per UUID-shaped input id.
|
||||
"""
|
||||
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
|
||||
out = {tid: False for tid in ids}
|
||||
if not ids or not user_id:
|
||||
return out
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT tool_id, in_chat FROM user_tool_preferences
|
||||
WHERE user_id = :user_id AND tool_id = ANY(CAST(:ids AS uuid[]))
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "ids": ids},
|
||||
).fetchall()
|
||||
for tool_id, in_chat in rows:
|
||||
out[str(tool_id)] = bool(in_chat)
|
||||
return out
|
||||
|
||||
def list_in_chat_tool_ids(self, user_id: str) -> list[str]:
|
||||
"""Ids of tools the user switched into their chats (any owner).
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
|
||||
Returns:
|
||||
Tool ids as strings; access must still be re-checked by the caller.
|
||||
"""
|
||||
if not user_id:
|
||||
return []
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT tool_id FROM user_tool_preferences
|
||||
WHERE user_id = :user_id AND in_chat = true
|
||||
ORDER BY updated_at
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows]
|
||||
@@ -9,6 +9,7 @@ Covers CRUD on workflow metadata:
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
@@ -85,12 +86,17 @@ class WorkflowsRepository:
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, workflow_id: str, user_id: str, fields: dict) -> bool:
|
||||
allowed = {"name", "description", "current_graph_version"}
|
||||
allowed = {"name", "description", "current_graph_version", "resource_sponsors"}
|
||||
filtered = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not filtered:
|
||||
return False
|
||||
|
||||
set_parts = [f"{col} = :{col}" for col in filtered]
|
||||
set_parts = [
|
||||
f"{col} = CAST(:{col} AS jsonb)" if col == "resource_sponsors" else f"{col} = :{col}"
|
||||
for col in filtered
|
||||
]
|
||||
if "resource_sponsors" in filtered:
|
||||
filtered["resource_sponsors"] = json.dumps(filtered["resource_sponsors"] or {})
|
||||
set_parts.append("updated_at = now()")
|
||||
params = {**filtered, "id": workflow_id, "user_id": user_id}
|
||||
|
||||
|
||||
Reference in new issue
Block a user