Files
DocsGPT/docsgpt/connectors/service.py
T
arc53-machine 3cab8af753 Keep the removed-connection note server-owned
Only removing a connection notes it on a kept tool. A config save through
update_tool or update_tool_config carries the stored note and ignores the
client's copy, so it can neither fake nor clear it; creating a tool, an
MCP server save and an agent import start without one. The note is now
written even for a connection with no catalog key, and a kept tool its
owner gave credentials of its own since runs again.
2026-09-29 18:12:54 +01:00

1705 lines
68 KiB
Python

"""Connection lifecycle: listing, status, and what each connection provides.
A connection is one ``connector_sessions`` row: a signed-in OAuth account, an
MCP server or a set of API credentials. Sources and tools point at it through
``connection_id``. Everything the API returns about a connection goes through
:func:`serialize_connection`, which never includes credentials.
"""
from __future__ import annotations
import json
import sys
from typing import Any, Iterable, Optional
from docsgpt.connectors import catalog
from docsgpt.connectors.catalog import ConnectorDefinition
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
owns_connector_session,
)
from docsgpt.storage.db.session import db_readonly, db_session
STATUS_CONNECTED = "connected"
STATUS_RECONNECT = "reconnect_needed"
STATUS_DISCONNECTED = "disconnected"
STATUS_ERROR = "error"
STATUS_PENDING = "pending"
# Worst first: a card shows the worst status among the user's accounts.
_STATUS_SEVERITY = {
STATUS_RECONNECT: 4,
STATUS_ERROR: 3,
STATUS_DISCONNECTED: 2,
STATUS_CONNECTED: 1,
STATUS_PENDING: 0,
}
def _json(value: Any) -> Any:
if isinstance(value, str):
try:
return json.loads(value)
except ValueError:
return None
return value
def has_credentials(row: dict) -> bool:
"""Whether a connection holds usable credentials (without decrypting them)."""
if row.get("encrypted_credentials"):
return True
return _has_plaintext_tokens(row)
def _has_plaintext_tokens(row: dict) -> bool:
"""Legacy plaintext tokens on a row not yet converted by migration 0040."""
token_info = _json(row.get("token_info")) or {}
if isinstance(token_info, dict) and token_info.get("access_token"):
return True
session_data = _json(row.get("session_data")) or {}
tokens = session_data.get("tokens") if isinstance(session_data, dict) else None
return bool(isinstance(tokens, dict) and tokens.get("access_token"))
def normalize_status(row: dict) -> str:
"""Map a row's stored status onto the connection status set.
Rows written before ``0040_connections`` use ``authorized`` for a
finished OAuth sign-in, and MCP rows carry no status at all; both are
read from whether the row holds credentials.
"""
raw = (row.get("status") or "").lower()
if raw in (STATUS_RECONNECT, STATUS_DISCONNECTED, STATUS_ERROR):
return raw
if raw in ("authorized", STATUS_CONNECTED, "active"):
return STATUS_CONNECTED
if raw == STATUS_PENDING:
# A legacy row whose plaintext tokens landed after the pending mark.
return STATUS_CONNECTED if _has_plaintext_tokens(row) else STATUS_PENDING
return STATUS_CONNECTED if has_credentials(row) else STATUS_PENDING
def worst_status(statuses: Iterable[str]) -> Optional[str]:
"""The status that needs the most attention, or None for an empty list."""
ranked = sorted(statuses, key=lambda s: _STATUS_SEVERITY.get(s, 0), reverse=True)
return ranked[0] if ranked else None
def account_label(row: dict) -> str:
"""What identifies the account to its owner: an email, a workspace or a host."""
return (
row.get("account_label")
or row.get("user_email")
or row.get("display_name")
or catalog.base_url(row.get("server_url")).split("://")[-1]
or ""
)
#: Longest name a user can give an account.
ACCOUNT_NAME_MAX = 80
def account_name(row: dict) -> str:
"""What the user calls the account, falling back to its label."""
return (row.get("account_name") or "").strip() or account_label(row)
def rename_connection(conn, row: dict, name: str) -> dict:
"""Give an account a name (an empty one clears it).
The name is only a name: the account is still found by its label when
its owner signs in again.
Args:
conn: Open connection inside a transaction.
row: The connection, already authorised for its owner.
name: The new name; surrounding spaces are dropped.
Returns:
The connection's public shape after the change.
"""
repo = ConnectorSessionsRepository(conn)
repo.update(str(row["id"]), {"account_name": name.strip() or None})
return serialize_connection(repo.get(str(row["id"])))
def with_account(name: str, account: str) -> str:
"""``Telegram · Alerts bot``: a tool's name with the account it uses."""
return f"{name} · {account}" if account and account not in name else name
def account_tool_names(conn, tools: Iterable[dict]) -> dict[str, str]:
"""Names for tools whose owner has several accounts of the tool's service.
With one Telegram bot a tool is just "Telegram"; with two, each tool
still named after the service becomes "Telegram · <account>", so people
(and the model) can tell them apart. A name the user chose is kept.
Args:
conn: Open database connection.
tools: ``user_tools`` rows.
Returns:
Tool id to its name, only for tools whose name changes.
"""
repo = ConnectorSessionsRepository(conn)
connections: dict[str, dict] = {}
for tool in tools:
connection_id = str(tool.get("connection_id") or "")
if connection_id and connection_id not in connections:
row = repo.get(connection_id)
if row is not None:
connections[connection_id] = row
counts: dict[tuple, int] = {}
for owner in {row["user_id"] for row in connections.values()}:
for row in repo.list_for_user(owner):
if normalize_status(row) != STATUS_PENDING:
key = (owner, catalog.connector_key_for_row(row))
counts[key] = counts.get(key, 0) + 1
names = {}
for tool in tools:
row = connections.get(str(tool.get("connection_id") or ""))
if row is None or counts.get((row["user_id"], catalog.connector_key_for_row(row)), 0) < 2:
continue
name = tool.get("custom_name") or tool.get("display_name") or ""
if name == serialize_connection(row)["name"]:
names[str(tool["id"])] = with_account(name, account_name(row))
return names
def _iso(value: Any) -> Optional[str]:
return value.isoformat() if hasattr(value, "isoformat") else value
def serialize_connection(row: dict, counts: Optional[dict] = None) -> dict:
"""Public shape of a connection. Never includes tokens or secrets."""
key = catalog.connector_key_for_row(row)
definition = catalog.get_definition(key)
counts = counts or {}
return {
"id": str(row["id"]),
"connector_key": key,
"name": (definition.name if definition and definition.publisher != "custom" else None)
or row.get("display_name")
or (definition.name if definition else key),
"display_name": row.get("display_name"),
"icon": definition.icon if definition else "tool_mcp_tool",
"account_label": account_label(row),
"account_name": (row.get("account_name") or "").strip() or None,
"auth_kind": row.get("auth_kind") or (definition.auth_kind if definition else None),
"status": normalize_status(row),
"server_url": row.get("server_url"),
"last_error": row.get("last_error"),
"created_at": _iso(row.get("created_at")),
"updated_at": _iso(row.get("updated_at")),
"last_used_at": _iso(row.get("last_used_at")),
"source_count": counts.get("sources", 0),
"tool_count": counts.get("tools", 0),
"credential_mode": row.get("credential_mode"),
}
def list_connections(conn, user_id: str) -> list[dict]:
"""The user's connections, finished sign-ins only, with resource counts."""
repo = ConnectorSessionsRepository(conn)
rows = [r for r in repo.list_for_user(user_id) if normalize_status(r) != STATUS_PENDING]
counts = repo.resource_counts([str(r["id"]) for r in rows])
return [serialize_connection(r, counts.get(str(r["id"]))) for r in rows]
def serialize_source(row: dict, connection_status: Optional[str] = None) -> dict:
"""A source linked to a connection, as the connection drawer lists it."""
metadata = _json(row.get("metadata")) or {}
sync_state = metadata.get("sync_state") if isinstance(metadata, dict) else None
if connection_status in (STATUS_RECONNECT, STATUS_DISCONNECTED) and not sync_state:
sync_state = "paused_reconnect"
return {
"id": str(row["id"]),
"name": row.get("name"),
"type": row.get("type"),
"last_sync": _iso(row.get("date")),
"sync_frequency": row.get("sync_frequency") or "never",
"sync_state": sync_state or "active",
}
def serialize_parameters(action: dict, account_parameters: Optional[dict] = None) -> list[dict]:
"""An action's parameters as the connection drawer shows them.
``fixed`` parameters are sent with ``value`` on every call and hidden
from the model; the others are left to it. A parameter the connection
itself sets (Telegram's default chat) is fixed with ``set_by: account``.
"""
account_parameters = account_parameters or {}
from docsgpt.agents.tool_pins import is_pinned, iter_parameters
schema = action.get("parameters") if isinstance(action.get("parameters"), dict) else {}
required = set(schema.get("required") or []) if isinstance(schema.get("required"), list) else set()
parameters = []
for _section, name, details in iter_parameters(action):
fixed = is_pinned(details)
entry = {
"name": name,
"description": details.get("description") or "",
"type": details.get("type") if isinstance(details.get("type"), str) else "string",
"required": bool(details.get("required")) or name in required,
"fixed": fixed,
"value": details.get("value") if fixed else None,
"set_by": "tool" if fixed else None,
}
if name in account_parameters:
entry.update(fixed=True, value=account_parameters[name], set_by="account")
parameters.append(entry)
return parameters
def serialize_tool(
row: dict, account_parameters: Optional[dict] = None, display_name: Optional[str] = None,
) -> dict:
"""A tool linked to a connection, with its actions, permissions and parameters.
Args:
row: The ``user_tools`` row.
account_parameters: Parameters its connection sets, from
:func:`connection_parameters`.
display_name: The name to show instead of the stored one, from
:func:`account_tool_names`.
"""
from docsgpt.connectors.permissions import action_access, action_permission
actions = []
for action in _json(row.get("actions")) or []:
if not isinstance(action, dict):
continue
access = action_access(row.get("name"), action)
actions.append(
{
"name": action.get("name"),
"description": action.get("description", ""),
"access": access,
"permission": action_permission(action),
"parameters": serialize_parameters(action, account_parameters),
}
)
return {
"id": str(row["id"]),
"name": row.get("name"),
"display_name": display_name or row.get("custom_name") or row.get("display_name") or row.get("name"),
"status": bool(row.get("status")),
"credential_mode": row.get("credential_mode") or "owner",
"actions": actions,
}
def connection_detail(conn, row: dict) -> dict:
"""A connection with the sources and tools it feeds."""
repo = ConnectorSessionsRepository(conn)
status = normalize_status(row)
sources = [serialize_source(s, status) for s in repo.list_sources(str(row["id"]))]
account_parameters = connection_parameters(row) if status == STATUS_CONNECTED else {}
tool_rows = repo.list_tools(str(row["id"]))
names = account_tool_names(conn, tool_rows)
tools = [serialize_tool(t, account_parameters, names.get(str(t["id"]))) for t in tool_rows]
detail = serialize_connection(row, {"sources": len(sources), "tools": len(tools)})
detail["sources"] = sources
detail["tools"] = tools
detail["writes"] = builtin_writes(catalog.get_definition(detail["connector_key"]), tool_rows)
return detail
def _card_state(definition: ConnectorDefinition, available: bool, disabled: bool, status: Optional[str]) -> str:
if disabled:
return "disabled"
if status in (STATUS_RECONNECT, STATUS_ERROR):
return "reconnect"
if status == STATUS_CONNECTED:
return "connected"
if not available:
return "needs_setup"
if definition.publisher == "custom":
return "custom"
return "available"
def catalog_for_user(conn, user_id: str, *, is_admin: bool, policies: Optional[dict] = None) -> list[dict]:
"""Catalog entries with availability and the caller's connection summary.
Args:
conn: Open database connection.
user_id: The caller.
is_admin: Admins see the names of missing server settings; everyone
else only learns that setup is needed.
policies: ``connector_key`` to policy row, when admin policies exist.
"""
if policies is None:
policies = load_policies(conn)
by_key: dict[str, list[str]] = {}
for connection in list_connections(conn, user_id):
by_key.setdefault(connection["connector_key"], []).append(connection["status"])
entries = []
for definition in catalog.all_definitions():
policy = policies.get(definition.key) or {}
disabled = not connector_is_enabled(policies, definition.key)
missing = definition.missing_settings
available = not missing and not disabled
statuses = by_key.get(definition.key, [])
if not available and not statuses:
# Members only see what they can use, plus what they connected
# before an admin turned it off (to manage or remove it).
continue
status = worst_status(statuses)
entries.append(
{
**definition.to_dict(),
"available": available,
"disabled": disabled,
"needs_setup": bool(missing),
"missing_settings": missing if is_admin else [],
"connected_count": sum(1 for s in statuses if s == STATUS_CONNECTED),
"connection_count": len(statuses),
"status": status,
"state": _card_state(definition, available, disabled, status),
"credential_policy": policy.get("credential_mode") or "choose",
"writes_allowed": writes_allowed(policies, definition.key),
}
)
return entries
def disconnect(conn, row: dict) -> dict:
"""Forget a connection's credentials but keep the row and what it feeds.
Revokes the grant at the provider where that is supported (Google);
a failed revocation never blocks forgetting the tokens here. Sources keep
their indexed content and pause syncing; tools stop working until the
account is reconnected. An MCP server's client registration is kept so
reconnecting skips dynamic client registration.
Args:
conn: Open database connection inside a transaction.
row: The connection row, already authorised for the caller.
Returns:
The connection's public shape after the change.
"""
from sqlalchemy import text
from docsgpt.security.encryption import CredentialDecryptionError
try:
secrets = read_secrets(row)
except CredentialDecryptionError:
secrets = {}
revoke_at_provider(row, secrets)
_forget_mcp_clients(row)
kept = {"client_info": secrets["client_info"]} if secrets.get("client_info") else {}
write_secrets(
conn, row, kept, status=STATUS_DISCONNECTED, session_token=None, last_error=None,
)
conn.execute(
text(
"UPDATE sources SET metadata = metadata || '{\"sync_state\": \"paused_reconnect\"}'::jsonb "
"WHERE connection_id = CAST(:id AS uuid)"
),
{"id": str(row["id"])},
)
return serialize_connection(ConnectorSessionsRepository(conn).get(str(row["id"])))
def revoke_at_provider(row: dict, secrets: dict) -> bool:
"""Best-effort revocation of an OAuth grant. Returns whether it succeeded.
Only Google offers a public revocation endpoint for these apps; Microsoft
and Atlassian tokens are simply forgotten locally.
"""
import logging
import requests
token_info = secrets.get("token_info") or {}
token = token_info.get("refresh_token") or token_info.get("access_token")
if row.get("provider") != "google_drive" or not token:
return False
try:
response = requests.post(
"https://oauth2.googleapis.com/revoke",
data={"token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
return response.status_code == 200
except requests.RequestException as exc:
logging.getLogger(__name__).warning("Google token revocation failed: %s", type(exc).__name__)
return False
# ---------------------------------------------------------------------------
# Credentials
# ---------------------------------------------------------------------------
#
# A connection's secrets are one dict, encrypted as a whole into
# ``encrypted_credentials``:
#
# token_info OAuth tokens of an ingest provider (Drive, SharePoint, Confluence)
# tokens MCP OAuth tokens
# client_info MCP dynamic client registration (client_id / client_secret)
# credentials API keys and other pasted secrets, by credential field key
#
# This module is the only caller of encrypt_json / decrypt_json for
# connections, and get_valid_token_info the only reader of OAuth tokens.
DECRYPT_ERROR = "Stored credentials could not be decrypted. Reconnect to continue."
_SECRET_SESSION_KEYS = ("tokens", "client_info")
class ConnectionUnavailable(ValueError):
"""A connection cannot be used until its owner reconnects it.
Attributes:
connection_id: The connection, when known.
status: ``reconnect_needed``, ``disconnected`` or ``missing``.
"""
def __init__(self, message: str, *, connection_id: Optional[str] = None, status: str = STATUS_RECONNECT):
super().__init__(message)
self.connection_id = connection_id
self.status = status
class TransientConnectionError(Exception):
"""The provider failed in a way worth retrying (5xx, rate limit, network)."""
class EncryptionKeyNotConfigured(Exception):
"""New credentials are refused while a multi-user install uses the public default key."""
def ensure_can_store_credentials() -> None:
"""Refuse to store new credentials under the public default encryption key.
Only multi-user installs (any ``AUTH_TYPE``) are refused; a single-user
local install keeps working and logs a warning at startup instead.
Raises:
EncryptionKeyNotConfigured: When the key is the default and auth is on.
"""
from docsgpt.core.settings import settings
from docsgpt.security.encryption import is_default_encryption_key
if settings.AUTH_TYPE and is_default_encryption_key():
raise EncryptionKeyNotConfigured("Set ENCRYPTION_SECRET_KEY before connecting services.")
def read_secrets(row: dict) -> dict:
"""Decrypt a connection's secrets.
Rows not yet converted by migration 0040 (or written by an older process
during a rolling deploy) still carry plaintext ``token_info`` and
``session_data`` tokens; those are read as they are.
Raises:
CredentialDecryptionError: The envelope cannot be decrypted.
"""
from docsgpt.security.encryption import decrypt_json
blob = row.get("encrypted_credentials")
if blob:
return decrypt_json(blob, row["user_id"])
secrets: dict = {}
token_info = _json(row.get("token_info"))
if isinstance(token_info, dict) and token_info:
secrets["token_info"] = token_info
session_data = _json(row.get("session_data")) or {}
if isinstance(session_data, dict):
for key in _SECRET_SESSION_KEYS:
if key in session_data:
secrets[key] = session_data[key]
return secrets
def _has_refresh(secrets: dict) -> bool:
token_info = secrets.get("token_info") or {}
tokens = secrets.get("tokens") or {}
return bool(
(isinstance(token_info, dict) and token_info.get("refresh_token"))
or (isinstance(tokens, dict) and tokens.get("refresh_token"))
)
def write_secrets(conn, row: dict, secrets: dict, **fields: Any) -> None:
"""Replace a connection's secrets and clear every plaintext copy.
Args:
conn: Open connection inside a transaction.
row: The connection row (only ``id``, ``user_id`` and ``session_data``
are read).
secrets: The complete secrets dict; empty clears the credentials.
**fields: Other columns to update in the same statement.
"""
from docsgpt.security.encryption import encrypt_json
session_data = _json(row.get("session_data")) or {}
if isinstance(session_data, dict):
session_data = {k: v for k, v in session_data.items() if k not in _SECRET_SESSION_KEYS}
else:
session_data = {}
update = {
"encrypted_credentials": encrypt_json(secrets, row["user_id"]) if secrets else None,
"has_refresh_token": _has_refresh(secrets),
"token_info": None,
"session_data": session_data,
**fields,
}
token_info = secrets.get("token_info")
if isinstance(token_info, dict) and token_info.get("scopes") and "scopes" not in fields:
scopes = token_info["scopes"]
update["scopes"] = scopes.split() if isinstance(scopes, str) else list(scopes)
ConnectorSessionsRepository(conn).update(str(row["id"]), update)
def credential_hint(credentials: dict) -> str:
"""``…abcd``: the last four characters of the first long secret, never more."""
for value in credentials.values():
if isinstance(value, str) and len(value) >= 8:
return "…" + value[-4:]
return "…"
def mark_reconnect_needed(connection_id: str, error: str) -> None:
"""Flag a connection whose credentials stopped working and tell its owner.
Pauses syncing on every source the connection feeds (no retry storm) and
publishes a ``connection.reconnect_needed`` user event, which the
frontend shows as a toast with a Reconnect action. Runs in its own
transaction so it sticks even when the caller's transaction rolls back.
"""
from sqlalchemy import text
from docsgpt.events.publisher import publish_user_event
with db_session() as conn:
repo = ConnectorSessionsRepository(conn)
row = repo.get(connection_id)
if row is None:
return
already = normalize_status(row) == STATUS_RECONNECT
counts = repo.resource_counts([connection_id]).get(str(connection_id), {})
repo.update(connection_id, {"status": STATUS_RECONNECT, "last_error": error[:500]})
conn.execute(
text(
"UPDATE sources SET metadata = metadata || '{\"sync_state\": \"paused_reconnect\"}'::jsonb "
"WHERE connection_id = CAST(:id AS uuid)"
),
{"id": connection_id},
)
if not already:
publish_user_event(
row["user_id"],
"connection.reconnect_needed",
{
"connection_id": connection_id,
"connector_key": catalog.connector_key_for_row(row),
"name": serialize_connection(row)["name"],
"source_count": counts.get("sources", 0),
"tool_count": counts.get("tools", 0),
},
scope={"kind": "connection", "id": connection_id},
)
def resume_sources(conn, connection_id: str) -> None:
"""Lift the reconnect pause from a connection's sources."""
from sqlalchemy import text
conn.execute(
text(
"UPDATE sources SET metadata = metadata - 'sync_state' "
"WHERE connection_id = CAST(:id AS uuid) AND metadata ? 'sync_state'"
),
{"id": connection_id},
)
def load_secrets(row: dict) -> Optional[dict]:
"""Decrypt a row's secrets; on failure flag it for reconnect and return None."""
from docsgpt.security.encryption import CredentialDecryptionError
try:
return read_secrets(row)
except CredentialDecryptionError:
mark_reconnect_needed(str(row["id"]), DECRYPT_ERROR)
return None
def _is_auth_failure(exc: BaseException) -> bool:
"""Whether a refresh failure means the grant is gone (vs. a retryable blip)."""
import requests
response = getattr(exc, "response", None)
status = getattr(response, "status_code", None)
if isinstance(exc, requests.exceptions.HTTPError) and status is not None:
return status in (400, 401, 403)
if isinstance(exc, (requests.exceptions.ConnectionError, requests.exceptions.Timeout)):
return False
message = str(exc).lower()
if any(word in message for word in ("timed out", "temporarily", "rate limit", "503", "502", "504")):
return False
try:
from google.auth.exceptions import RefreshError, TransportError
if isinstance(exc, TransportError):
return False
if isinstance(exc, RefreshError):
return True
except ImportError: # pragma: no cover - google libs are core deps
pass
return True
def connection_id_for_session_token(session_token: Optional[str]) -> str:
"""The connection behind a legacy browser session token.
Raises:
ConnectionUnavailable: No connection holds that token.
"""
from docsgpt.parser.connectors._auth_utils import session_token_fingerprint
row = None
if session_token:
with db_readonly() as conn:
row = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
if row is None:
raise ConnectionUnavailable(
f"Invalid session token ({session_token_fingerprint(session_token or '')})", status="missing",
)
return str(row["id"])
def get_valid_token_info(connection_id: str, *, rejected_access_token: Optional[str] = None) -> dict:
"""An unexpired OAuth ``token_info`` for an ingest connection.
Holds a row lock (``SELECT ... FOR UPDATE``) while it checks expiry and
refreshes, and writes the rotated refresh token back in the same
transaction, so two workers refreshing a rotating token (Microsoft,
Atlassian) never spend the same one twice: the second waits and then
reads the first one's fresh token.
Args:
connection_id: The connection to use.
rejected_access_token: An access token the provider just answered
with 401. The token is refreshed when it is still the stored one;
when another worker already replaced it, the new one is returned.
Returns:
The decrypted token info, refreshed if it was about to expire.
Raises:
ConnectionUnavailable: The connection is missing, disconnected, cannot
be decrypted, or its grant was revoked (it is then flagged
``reconnect_needed``).
TransientConnectionError: The provider failed in a retryable way.
"""
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
from docsgpt.security.encryption import CredentialDecryptionError
failure: Optional[str] = None
transient: Optional[BaseException] = None
token_info: Optional[dict] = None
with db_session() as conn:
repo = ConnectorSessionsRepository(conn)
row = repo.get_for_update(connection_id)
if row is None:
raise ConnectionUnavailable("Connection not found", connection_id=connection_id, status="missing")
status = normalize_status(row)
if status == STATUS_DISCONNECTED:
raise ConnectionUnavailable(
"Connection is disconnected", connection_id=connection_id, status=STATUS_DISCONNECTED
)
try:
secrets = read_secrets(row)
except CredentialDecryptionError:
secrets, failure = {}, DECRYPT_ERROR
token_info = secrets.get("token_info") if not failure else None
if not failure and not token_info:
failure = "No stored sign-in for this connection. Reconnect to continue."
if not failure:
auth = ConnectorCreator.create_auth(row["provider"])
rejected = bool(rejected_access_token) and token_info.get("access_token") == rejected_access_token
if rejected or auth.is_token_expired(token_info):
refresh_token = token_info.get("refresh_token")
if not refresh_token:
failure = "The sign-in expired and cannot be renewed. Reconnect to continue."
else:
try:
refreshed = auth.refresh_access_token(refresh_token)
except Exception as exc: # classified below
if _is_auth_failure(exc):
failure = f"The provider rejected the stored sign-in: {type(exc).__name__}"
else:
transient = exc
else:
merged = {**token_info, **{k: v for k, v in refreshed.items() if v is not None}}
token_info = auth.sanitize_token_info(merged)
write_secrets(
conn, row, {**secrets, "token_info": token_info},
status=STATUS_CONNECTED, last_error=None,
)
if not failure and transient is None:
repo.update(connection_id, {"last_used_at": _now()})
if failure:
mark_reconnect_needed(connection_id, failure)
raise ConnectionUnavailable(failure, connection_id=connection_id)
if transient is not None:
raise TransientConnectionError(str(transient)) from transient
return token_info
def _now():
import datetime
return datetime.datetime.now(datetime.timezone.utc)
def get_credentials(row: dict) -> dict:
"""The pasted credentials (API keys) of an ``api_key`` connection.
Raises:
ConnectionUnavailable: Disconnected, flagged, or undecryptable.
"""
status = normalize_status(row)
if status in (STATUS_DISCONNECTED, STATUS_RECONNECT):
raise ConnectionUnavailable(f"Connection is {status}", connection_id=str(row["id"]), status=status)
secrets = load_secrets(row)
if secrets is None:
raise ConnectionUnavailable(DECRYPT_ERROR, connection_id=str(row["id"]))
return dict(secrets.get("credentials") or {})
def access_credentials(row: dict) -> dict:
"""What a loader or tool authenticates with through this connection.
Pasted credentials for an ``api_key`` connection. An OAuth connection
whose token a loader or tool sends itself (GitHub's App sign-in) gives
its current access token as ``access_token``, refreshed first when it
has expired.
Raises:
ConnectionUnavailable: Disconnected, flagged, undecryptable, or the
refresh was refused (the connection is then flagged).
TransientConnectionError: The provider failed in a retryable way.
"""
if (row.get("auth_kind") or "") == "oauth":
status = normalize_status(row)
if status in (STATUS_DISCONNECTED, STATUS_RECONNECT):
raise ConnectionUnavailable(f"Connection is {status}", connection_id=str(row["id"]), status=status)
return {"access_token": get_valid_token_info(str(row["id"])).get("access_token")}
return get_credentials(row)
def connection_parameters(row: Optional[dict]) -> dict:
"""Tool parameters a connection sets, from its credential fields.
Only fields the catalog marks with ``parameter`` count (Telegram's
default chat); an empty value sets nothing. A connection that cannot be
read sets nothing either: the call then fails on its credentials.
Args:
row: The connection row, or None.
Returns:
Parameter name to value.
"""
if row is None:
return {}
fields = catalog.parameter_fields(catalog.connector_key_for_row(row))
if not fields:
return {}
try:
credentials = get_credentials(row)
except ConnectionUnavailable:
return {}
return {
f.parameter: credentials[f.key]
for f in fields
if credentials.get(f.key) not in (None, "")
}
def _api_key_account(
repo: ConnectorSessionsRepository,
user_id: str,
connector_key: str,
server_url: Optional[str],
label: str,
credentials: dict,
) -> tuple[Optional[dict], str]:
"""The connection holding exactly ``credentials``, or a free label for a new one.
A label (a hint of the key, or what the user typed) is not an identity:
two different keys can share it. A row under the label is reused only
when it holds the same credentials; otherwise the label gets a
``(2)``, ``(3)`` suffix until it names a matching row or no row.
Returns:
``(row, label)``: the row to reuse (None to create one) and its label.
"""
from docsgpt.security.encryption import CredentialDecryptionError
candidate, suffix = label, 1
while True:
existing = repo.find_account(user_id, connector_key, server_url=server_url, account_label=candidate)
if existing is None:
return None, candidate
try:
if (read_secrets(existing).get("credentials") or {}) == credentials:
return existing, candidate
except CredentialDecryptionError:
pass
suffix += 1
candidate = f"{label} ({suffix})"
def create_api_key_connection(
conn,
user_id: str,
definition,
credentials: dict,
*,
label: Optional[str] = None,
server_url: Optional[str] = None,
display_name: Optional[str] = None,
) -> tuple[dict, bool]:
"""Store a connection for pasted credentials, or reuse the matching one.
The same credentials for the same service map to one connection
("enter secrets once"): adding a second tool or bucket reuses it.
Args:
conn: Open connection inside a transaction.
user_id: The owner.
definition: The catalog entry (``api_key`` or ``custom_mcp``).
credentials: Field key to value, as the user entered them.
label: What the account is called; defaults to a hint of the key.
server_url: Base URL, for custom MCP servers.
display_name: Name shown for custom connectors.
Returns:
``(row, created)``.
Raises:
ConnectorDisabled: An admin turned the connector off. Checked first,
so callers that fall back to a legacy path on the other errors
never do so for a disabled connector. A custom MCP server at a
preset's address is that preset (see
:func:`catalog.connector_key_for_row`), so its switch applies.
ValueError: A required credential field is missing.
EncryptionKeyNotConfigured: See :func:`ensure_can_store_credentials`.
"""
preset = catalog.preset_for_url(server_url) if definition.key == "custom_mcp" else None
ensure_connector_allowed(conn, preset.key if preset else definition.key)
fields = {f.key: f for f in definition.credential_fields}
if fields:
missing = [f.label for f in fields.values() if f.required and not str(credentials.get(f.key) or "").strip()]
if missing:
raise ValueError(f"Missing credentials: {', '.join(missing)}")
credentials = {k: v for k, v in credentials.items() if k in fields and v not in (None, "")}
else:
credentials = {k: v for k, v in credentials.items() if v not in (None, "")}
ensure_can_store_credentials()
secret_values = {k: v for k, v in credentials.items() if (fields.get(k).secret if fields.get(k) else True)}
repo = ConnectorSessionsRepository(conn)
existing, account_label = _api_key_account(
repo, user_id, definition.key, server_url, label or credential_hint(secret_values or credentials), credentials,
)
if existing is not None:
write_secrets(conn, existing, {"credentials": credentials}, status=STATUS_CONNECTED, last_error=None)
resume_sources(conn, str(existing["id"]))
return repo.get(str(existing["id"])), False
from docsgpt.security.encryption import encrypt_json
row = repo.create(
user_id,
definition.key,
connector_key=definition.key,
auth_kind="api_key",
display_name=display_name or definition.name,
account_label=account_label,
server_url=server_url,
encrypted_credentials=encrypt_json({"credentials": credentials}, user_id),
)
if row is None: # lost a race with an identical insert
row = repo.find_account(user_id, definition.key, server_url=server_url, account_label=account_label)
return row, False
return row, True
# ---------------------------------------------------------------------------
# OAuth sign-in
# ---------------------------------------------------------------------------
def begin_oauth(conn, user_id: str, provider: str, connection_id: Optional[str] = None) -> dict:
"""The row an OAuth sign-in writes into when it completes.
Reconnecting passes the connection being reconnected. A new sign-in
gets a pending row with no account label; the callback moves the tokens
onto the existing row for that account if there is one.
Raises:
ConnectionUnavailable: ``connection_id`` is not the caller's
connection for ``provider``.
"""
from sqlalchemy import text
repo = ConnectorSessionsRepository(conn)
if connection_id:
row = repo.get_for_user(connection_id, user_id)
if row is None or row.get("provider") != provider:
raise ConnectionUnavailable("Connection not found", connection_id=connection_id, status="missing")
return row
ensure_connector_allowed(conn, provider)
definition = catalog.get_definition(provider)
result = conn.execute(
text(
"""
INSERT INTO connector_sessions (user_id, provider, status, connector_key, auth_kind, display_name)
VALUES (:user_id, :provider, 'pending', :provider, 'oauth', :display_name)
ON CONFLICT (user_id, provider, COALESCE(server_url, ''), COALESCE(account_label, ''))
DO UPDATE SET status = CASE
WHEN connector_sessions.status IN ('connected', 'authorized') THEN connector_sessions.status
ELSE 'pending' END
RETURNING *
"""
),
{"user_id": user_id, "provider": provider, "display_name": definition.name if definition else provider},
)
from docsgpt.storage.db.base_repository import row_to_dict
return row_to_dict(result.fetchone())
def complete_oauth(conn, state_row: dict, provider: str, token_info: dict, account: str) -> dict:
"""Store a finished OAuth sign-in and return the connection it belongs to.
Signing in to an account that already has a connection updates that
connection (and drops the pending placeholder), so reconnecting from any
entry point heals every source and tool of the account. Reconnecting a
connection but signing in to a different account never rewrites it (its
sources and tools would silently run as the new account): the new
account gets its own connection and the original keeps its status.
Args:
conn: Open connection inside a transaction.
state_row: The row named in the OAuth ``state``.
provider: ``google_drive``, ``share_point`` or ``confluence``.
token_info: Sanitised token info from the provider.
account: The account's email or name, shown as "Connected as".
Returns:
The connection now holding the sign-in.
"""
import uuid
repo = ConnectorSessionsRepository(conn)
target = state_row
definition = catalog.get_definition(provider)
display_name = definition.name if definition else provider
existing = repo.find_account(state_row["user_id"], provider, server_url=None, account_label=account)
if existing is not None and str(existing["id"]) != str(state_row["id"]):
target = existing
if not has_credentials(state_row) and normalize_status(state_row) == STATUS_PENDING:
repo.delete_by_id(str(state_row["id"]))
elif existing is None and state_row.get("account_label"):
# The row belongs to another account (a reconnect that signed in as someone else).
target = repo.create(
state_row["user_id"], provider, connector_key=provider, auth_kind="oauth",
display_name=display_name, account_label=account, status=STATUS_PENDING,
) or repo.find_account(state_row["user_id"], provider, server_url=None, account_label=account)
write_secrets(
conn,
target,
{"token_info": token_info},
status=STATUS_CONNECTED,
# Kept for frontends from before connections; new ones use the id.
session_token=str(uuid.uuid4()),
user_email=account,
account_label=account,
connector_key=provider,
auth_kind="oauth",
display_name=display_name,
last_error=None,
)
resume_sources(conn, str(target["id"]))
return repo.get(str(target["id"]))
def picker_token(connection_id: str) -> dict:
"""What a browser-side picker needs: a short-lived access token, never the refresh token."""
token_info = get_valid_token_info(connection_id)
return {
"access_token": token_info.get("access_token"),
"expiry": token_info.get("expiry"),
"allows_shared_content": bool(token_info.get("allows_shared_content")),
}
def claim_session_token(conn, user_id: str, provider: str, session_token: str) -> Optional[dict]:
"""Link a legacy browser session token to its connection, once.
Returns the connection when the token belongs to ``user_id`` and
``provider``. The token itself keeps working for the session-token
routes during this release, so an older tab does not break.
"""
repo = ConnectorSessionsRepository(conn)
row = repo.get_by_session_token(session_token)
if not row or row.get("user_id") != user_id or (row.get("provider") or "").lower() != provider.lower():
return None
return row
def resolve_request_connection(user_id: str, provider: Optional[str], data: dict) -> Optional[dict]:
"""The caller's connection a request names, by ``connection_id`` or legacy ``session_token``.
Returns None unless the caller owns the connection and it belongs to
``provider`` (when given).
"""
connection_id = data.get("connection_id")
session_token = data.get("session_token")
if not connection_id and not session_token:
return None
with db_readonly() as conn:
repo = ConnectorSessionsRepository(conn)
if connection_id:
row = repo.get_for_user(str(connection_id), user_id)
else:
row = repo.get_by_session_token(session_token)
if not owns_connector_session(row, user_id, provider):
return None
if row is None:
return None
if provider and (row.get("provider") or "").lower() != provider.lower():
return None
return row
# ---------------------------------------------------------------------------
# MCP OAuth token storage
# ---------------------------------------------------------------------------
def mcp_provider(base_url: str) -> str:
"""``provider`` value MCP OAuth connections are stored under."""
return f"mcp:{base_url}"
def _mcp_row(conn, user_id: str, base_url: str, connection_id: Optional[str], *, lock: bool = False):
from sqlalchemy import text
from docsgpt.storage.db.base_repository import row_to_dict
repo = ConnectorSessionsRepository(conn)
if connection_id:
row = repo.get_for_update(connection_id) if lock else repo.get(connection_id)
# A connection's tokens only ever go to its own server. Ownership is
# checked where the id is chosen (the tool executor); clients cannot
# supply one (see ``_sanitize_mcp_transport``).
if row is None or row.get("provider") != mcp_provider(base_url):
return None
return row
result = conn.execute(
text(
"SELECT * FROM connector_sessions WHERE user_id = :user_id AND provider = :provider "
"ORDER BY updated_at DESC LIMIT 1" + (" FOR UPDATE" if lock else "")
),
{"user_id": user_id, "provider": mcp_provider(base_url)},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def read_mcp_secrets(user_id: str, base_url: str, connection_id: Optional[str] = None) -> dict:
"""The MCP OAuth ``tokens`` and ``client_info`` of a server connection."""
with db_readonly() as conn:
row = _mcp_row(conn, user_id, base_url, connection_id)
if row is None:
return {}
return load_secrets(row) or {}
def update_mcp_secrets(
user_id: str,
base_url: str,
patch: dict,
*,
connection_id: Optional[str] = None,
status: Optional[str] = None,
) -> dict:
"""Merge ``patch`` into an MCP connection's secrets (``None`` drops a key).
Creates the connection on first use, named after the matching preset or
the server's host. A write for a named connection never creates one: a
client or sync still running when its connection was removed would
otherwise bring it back, holding the tokens it just renewed.
Returns:
The connection row after the update.
Raises:
ConnectionUnavailable: ``connection_id`` names no connection of this
user for this server (removed, or another server's).
"""
from docsgpt.security.encryption import CredentialDecryptionError
with db_session() as conn:
repo = ConnectorSessionsRepository(conn)
row = _mcp_row(conn, user_id, base_url, connection_id, lock=True)
if row is None and connection_id:
raise ConnectionUnavailable("Connection not found", connection_id=connection_id, status="missing")
if row is None:
row = repo.merge_session_data(user_id, mcp_provider(base_url), base_url, {})
try:
secrets = read_secrets(row)
except CredentialDecryptionError:
secrets = {}
for key, value in patch.items():
if value is None:
secrets.pop(key, None)
else:
secrets[key] = value
fields: dict = {}
if status:
fields["status"] = status
if status == STATUS_CONNECTED:
fields["last_error"] = None
elif not row.get("status"):
fields["status"] = STATUS_CONNECTED if secrets.get("tokens") else STATUS_PENDING
if not row.get("connector_key"):
preset = catalog.preset_for_url(base_url)
fields["connector_key"] = preset.key if preset else "custom_mcp"
fields["auth_kind"] = "mcp_oauth"
fields["display_name"] = preset.name if preset else base_url.split("://")[-1]
write_secrets(conn, row, secrets, **fields)
if fields.get("status") == STATUS_CONNECTED:
resume_sources(conn, str(row["id"]))
return repo.get(str(row["id"]))
# ---------------------------------------------------------------------------
# Tools created from a connection
# ---------------------------------------------------------------------------
def _transform_actions(actions: list) -> list:
"""``transform_actions`` from the tools API: active, LLM-filled parameters."""
transformed = []
for action in actions:
action = dict(action)
action["active"] = True
parameters = action.get("parameters")
if isinstance(parameters, dict):
for details in (parameters.get("properties") or {}).values():
if isinstance(details, dict):
details["filled_by_llm"] = True
details["value"] = ""
transformed.append(action)
return transformed
def create_tool_for_connection(
conn,
user_id: str,
connection: dict,
*,
template: Optional[str] = None,
display_name: Optional[str] = None,
config: Optional[dict] = None,
actions: Optional[list] = None,
permissions: Optional[dict] = None,
status: bool = True,
) -> dict:
"""Create the tool a connection provides, with read / write defaults.
Writes default to "Needs approval", reads to "Always allow"; the
``permissions`` map (action name to ``always`` / ``ask`` / ``off``)
overrides them. Secrets are never copied onto the tool: the executor
reads them from the connection at run time.
Args:
conn: Open connection inside a transaction.
user_id: The owner of the connection and the new tool.
connection: The connection row.
template: ``user_tools`` name; defaults to the connector's first template.
display_name: Name shown for the tool.
config: Non-secret tool configuration (an MCP server URL, say).
actions: Action metadata; defaults to the tool class's own.
permissions: Per-action permission overrides.
status: Whether the tool starts enabled.
Returns:
The new ``user_tools`` row.
"""
from docsgpt.agents.tools.tool_manager import ToolManager
from docsgpt.connectors.permissions import apply_default_permissions, apply_permission
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
key = catalog.connector_key_for_row(connection)
definition = catalog.get_definition(key)
template = template or (definition.tool_templates[0] if definition and definition.tool_templates else None)
if not template:
raise ValueError(f"Connector {key} provides no tool")
tool = ToolManager(config={}).tools.get(template)
if tool is None:
raise ValueError(f"Unknown tool template: {template}")
doc = (tool.__doc__ or template).strip().split("\n", 1)
if actions is None:
actions = tool.get_actions_metadata()
actions = apply_default_permissions(template, _transform_actions(actions))
for index, action in enumerate(actions):
permission = (permissions or {}).get(action.get("name"))
if permission:
actions[index] = apply_permission(action, permission)
name = display_name or (serialize_connection(connection)["name"] if connection else doc[0].strip())
return UserToolsRepository(conn).create(
user_id,
template,
config=dict(config or {}),
custom_name=name,
display_name=name,
description=doc[1].strip() if len(doc) > 1 else "",
config_requirements=tool.get_config_requirements(),
actions=actions,
status=status,
connection_id=str(connection["id"]),
)
def split_secrets(config: dict, config_requirements: dict) -> tuple[dict, dict]:
"""``(public, secrets)``: a tool config split by its secret requirements."""
secret_keys = {k for k, spec in (config_requirements or {}).items() if spec.get("secret")}
public = {k: v for k, v in config.items() if k not in secret_keys}
secrets = {k: v for k, v in config.items() if k in secret_keys and v not in (None, "")}
return public, secrets
def builtin_mcp_config(definition: Optional[ConnectorDefinition], writes: bool = False) -> Optional[dict]:
"""Tool config of a built-in connector whose tool is its service's MCP server.
The token is not part of it: the executor reads it from the connection
at run time and sends it as a bearer token.
Args:
definition: The connector.
writes: Use the endpoint that also offers write actions, when the
connector has one (GitHub's full server).
Returns:
The config, or None when the connector has no such tool.
"""
if (
definition is None
or definition.publisher != "built_in"
or not definition.mcp_url
or "mcp_tool" not in definition.tool_templates
):
return None
url = definition.mcp_write_url if writes and definition.mcp_write_url else definition.mcp_url
return {"server_url": url, "auth_type": "bearer", "transport_type": "http", "timeout": 30}
def builtin_mcp_url(definition: Optional[ConnectorDefinition], stored_url: Optional[str], allowed: bool) -> str:
"""The endpoint a built-in connector's MCP tool may call, whatever its config says.
Only the connector's own endpoints are ever used: the write endpoint when
the tool was set up for writes and an admin allows them, the read-only
one otherwise (another path on the same host included).
Args:
definition: The connector, with ``mcp_url`` set.
stored_url: The ``server_url`` in the tool's config.
allowed: Whether an admin allows writes through this connector.
"""
if allowed and definition.mcp_write_url and stored_url == definition.mcp_write_url:
return definition.mcp_write_url
return definition.mcp_url or ""
def builtin_writes(definition: Optional[ConnectorDefinition], tool_rows: Iterable[dict]) -> Optional[bool]:
"""Whether a connection's built-in MCP tool is set up for writes.
Returns:
None when the connector offers no writes or the connection has no such
tool yet; otherwise whether the tool uses the write endpoint.
"""
if definition is None or not definition.mcp_write_url or builtin_mcp_config(definition) is None:
return None
for tool in tool_rows:
if tool.get("name") == "mcp_tool":
return (_json(tool.get("config")) or {}).get("server_url") == definition.mcp_write_url
return None
def needs_mcp_discovery(conn, connection: dict) -> bool:
"""Whether creating this connection's tools first needs its MCP server's actions."""
definition = catalog.get_definition(catalog.connector_key_for_row(connection))
if builtin_mcp_config(definition) is None:
return False
have = {tool.get("name") for tool in ConnectorSessionsRepository(conn).list_tools(str(connection["id"]))}
return "mcp_tool" not in have
def ensure_connection_tools(
conn,
user_id: str,
connection: dict,
permissions: Optional[dict] = None,
mcp_actions: Optional[list] = None,
mcp_writes: bool = False,
) -> list[dict]:
"""Create the connector's tools once; later calls return the existing ones.
This is what makes the setup step idempotent for tools: a retried or
repeated setup never creates a second Telegram tool for the same bot.
Args:
conn: Open connection inside a transaction.
user_id: The owner.
connection: The connection row.
permissions: Per-action permission overrides.
mcp_actions: The actions of a built-in connector's MCP server
(GitHub's), discovered by the caller outside this transaction.
Without them that tool is not created.
mcp_writes: Point that tool at the endpoint that also offers writes;
the caller checked that an admin allows them.
"""
repo = ConnectorSessionsRepository(conn)
existing = repo.list_tools(str(connection["id"]))
key = catalog.connector_key_for_row(connection)
definition = catalog.get_definition(key)
if not definition or not definition.tool_templates:
return existing
have = {tool.get("name") for tool in existing}
mcp_config = builtin_mcp_config(definition, writes=mcp_writes)
created = []
for template in definition.tool_templates:
if template in have:
continue
if template == "mcp_tool" and mcp_config is not None and mcp_actions is not None:
created.append(create_tool_for_connection(
conn, user_id, connection, template=template, config=mcp_config,
actions=mcp_actions, permissions=permissions,
))
continue
if template in ("mcp_tool", "api_tool"):
# Other MCP tools are created by the MCP save flow, which has the
# discovered actions; OpenAPI tools come from an imported spec.
continue
created.append(
create_tool_for_connection(conn, user_id, connection, template=template, permissions=permissions)
)
return existing + created
def _forget_mcp_clients(row: dict) -> None:
"""Drop the MCP clients this process cached for a connection's tokens.
A cached client keeps the tokens it signed in with for a few minutes, so
after the connection is removed or disconnected it would still answer as
signed in (and save a tool with no connection behind it).
"""
if not str(row.get("provider") or "").startswith("mcp:") and row.get("auth_kind") != "mcp_oauth":
return
# Not imported here: a process that never loaded the MCP tool has no
# clients cached, and importing it from this module would be circular.
mcp_tool = sys.modules.get("docsgpt.agents.tools.mcp_tool")
if mcp_tool is not None:
mcp_tool.forget_cached_clients(str(row["id"]), str(row.get("user_id") or ""))
def remove_connection(conn, row: dict, *, sources: str = "keep", tools: str = "delete") -> list[dict]:
"""Delete a connection, choosing what happens to what it feeds.
Args:
conn: Open connection inside a transaction.
row: The connection, already authorised for its owner.
sources: ``keep`` (content stays, syncing stops) or ``delete``.
tools: ``delete`` (the default: they cannot work without it) or ``keep``.
Returns:
The source rows the caller must delete with their indexes (empty
when ``sources`` is ``keep``). Index files live outside the database,
so the caller deletes them after this transaction commits.
"""
from sqlalchemy import text
from docsgpt.security.encryption import CredentialDecryptionError
repo = ConnectorSessionsRepository(conn)
connection_id = str(row["id"])
_forget_mcp_clients(row)
linked_sources = repo.list_sources(connection_id)
try:
revoke_at_provider(row, read_secrets(row))
except CredentialDecryptionError:
pass
if tools == "delete":
# Team grants go with the tools (delete trigger) and chat preferences
# by FK cascade; the owner's sharing switches have no FK.
conn.execute(
text(
"WITH gone AS (DELETE FROM user_tools WHERE connection_id = CAST(:id AS uuid) "
"AND user_id = :user_id RETURNING id) "
"DELETE FROM resource_share_settings WHERE resource_type = 'tool' "
"AND resource_id IN (SELECT id FROM gone)"
),
{"id": connection_id, "user_id": row["user_id"]},
)
else:
# The link itself goes with the row (ON DELETE SET NULL); note which
# service the tools lost, so an agent can say why they stopped.
conn.execute(
text(
"UPDATE user_tools SET status = false, "
"config = COALESCE(config, '{}'::jsonb) || jsonb_build_object('removed_connection', "
"CAST(:marker AS jsonb)) WHERE connection_id = CAST(:id AS uuid)"
),
{"id": connection_id, "marker": json.dumps(catalog.connector_key_for_row(row) or True)},
)
conn.execute(
text(
"UPDATE sources SET sync_frequency = 'never', metadata = metadata - 'sync_state' "
"WHERE connection_id = CAST(:id AS uuid)"
),
{"id": connection_id},
)
repo.delete_by_id(connection_id)
return linked_sources if sources == "delete" else []
def set_tool_permissions(
conn, user_id: str, connection_id: str, tool_id: str, permissions: dict,
) -> Optional[dict]:
"""Apply ``{action: always | ask | off}`` to a tool the user owns.
Args:
conn: Open database connection.
user_id: The caller, who must own the tool.
connection_id: The connection the tool must belong to.
tool_id: The tool to update.
permissions: Action name to ``always``, ``ask`` or ``off``.
Returns:
The updated tool, or None (nothing written) when the tool is not the
user's or belongs to another connection.
"""
from docsgpt.connectors.permissions import apply_permission
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
tools = UserToolsRepository(conn)
tool = tools.get_any(tool_id, user_id)
if tool is None or tool.get("user_id") != user_id or str(tool.get("connection_id")) != connection_id:
return None
actions = [
apply_permission(action, permissions[action.get("name")]) if action.get("name") in permissions else action
for action in (_json(tool.get("actions")) or [])
]
tools.update(str(tool["id"]), user_id, {"actions": actions})
return tools.get_any(str(tool["id"]), user_id)
def set_tool_parameters(
conn, user_id: str, connection_id: str, tool_id: str, action_name: str, pins: dict,
) -> Optional[dict]:
"""Fix or release parameters of one action of a tool the user owns.
Args:
conn: Open database connection.
user_id: The caller, who must own the tool.
connection_id: The connection the tool must belong to.
tool_id: The tool to update.
action_name: The action whose parameters change.
pins: Parameter name to the value to always use, or None to let the
model decide.
Returns:
The updated tool, or None (nothing written) when the tool is not the
user's or belongs to another connection.
Raises:
ValueError: The action or a parameter does not exist, or a value does
not fit its parameter.
"""
from docsgpt.agents.tool_pins import set_pins
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
tools = UserToolsRepository(conn)
tool = tools.get_any(tool_id, user_id)
if tool is None or tool.get("user_id") != user_id or str(tool.get("connection_id")) != connection_id:
return None
actions = _json(tool.get("actions")) or []
if not any(isinstance(a, dict) and a.get("name") == action_name for a in actions):
raise ValueError(f"Unknown action: {action_name}")
actions = [
set_pins(action, pins) if isinstance(action, dict) and action.get("name") == action_name else action
for action in actions
]
tools.update(str(tool["id"]), user_id, {"actions": actions})
return tools.get_any(str(tool["id"]), user_id)
def reencrypt_all(batch_size: int = 500) -> dict:
"""Rewrite every connection's credentials with the current key.
Run after rotating ENCRYPTION_SECRET_KEY with the old value in
ENCRYPTION_SECRET_KEY_PREVIOUS; afterwards the previous key can go.
Returns:
``{"rewritten": n, "current": n, "failed": n}``; failed rows (neither
key opens them) are flagged ``reconnect_needed``.
"""
from sqlalchemy import text
from docsgpt.security.encryption import CredentialDecryptionError, current_key_id, envelope_key_id
key_id = current_key_id()
counts = {"rewritten": 0, "current": 0, "failed": 0}
failed_ids: list[str] = []
last_id = "00000000-0000-0000-0000-000000000000"
while True:
with db_session() as conn:
rows = conn.execute(
text(
"SELECT * FROM connector_sessions WHERE encrypted_credentials IS NOT NULL "
"AND id > CAST(:last AS uuid) ORDER BY id LIMIT :batch FOR UPDATE"
),
{"last": last_id, "batch": batch_size},
).fetchall()
if not rows:
break
from docsgpt.storage.db.base_repository import row_to_dict
for raw in rows:
row = row_to_dict(raw)
last_id = str(row["id"])
if envelope_key_id(row["encrypted_credentials"]) == key_id:
counts["current"] += 1
continue
try:
secrets = read_secrets(row)
except CredentialDecryptionError:
counts["failed"] += 1
failed_ids.append(last_id)
continue
write_secrets(conn, row, secrets)
counts["rewritten"] += 1
for connection_id in failed_ids:
mark_reconnect_needed(connection_id, DECRYPT_ERROR)
return counts
# ---------------------------------------------------------------------------
# Admin policy
# ---------------------------------------------------------------------------
class ConnectorDisabled(Exception):
"""An admin turned this connector (or custom MCP servers) off."""
class WritesForbidden(Exception):
"""An admin forbade write actions through this connector (GitHub's changes)."""
def custom_mcp_allowed(conn) -> bool:
"""The instance-wide "Allow custom MCP servers" switch (on unless turned off)."""
from docsgpt.storage.db.repositories.app_metadata import AppMetadataRepository
from docsgpt.storage.db.repositories.connector_policies import ALLOW_CUSTOM_MCP_KEY
return AppMetadataRepository(conn).get(ALLOW_CUSTOM_MCP_KEY) != "false"
def connector_is_enabled(policies: dict, connector_key: Optional[str]) -> bool:
"""Whether a connector is switched on.
An admin's explicit switch wins. Without one, a connector is on when it
has the server settings it needs, so one that still needs admin setup
starts off and members never see it.
Args:
policies: ``connector_key`` to policy row, from :func:`load_policies`.
connector_key: The catalog key.
"""
explicit = (policies.get(connector_key) or {}).get("enabled") if connector_key else None
if explicit is not None:
return bool(explicit)
definition = catalog.get_definition(connector_key) if connector_key else None
return definition is None or definition.configured
def load_policies(conn) -> dict[str, dict]:
"""Every connector's policy, with custom MCP and write switches folded in.
A connector that offers writes (GitHub) gets ``allow_writes``: False when
an admin forbade them.
"""
from docsgpt.storage.db.repositories.app_metadata import AppMetadataRepository
from docsgpt.storage.db.repositories.connector_policies import ConnectorPoliciesRepository, allow_writes_key
policies = dict(ConnectorPoliciesRepository(conn).all())
if not custom_mcp_allowed(conn):
policies["custom_mcp"] = {**policies.get("custom_mcp", {}), "enabled": False}
metadata = AppMetadataRepository(conn)
for definition in catalog.all_definitions():
if definition.mcp_write_url and metadata.get(allow_writes_key(definition.key)) == "false":
policies[definition.key] = {**policies.get(definition.key, {}), "allow_writes": False}
return policies
def writes_allowed(policies: dict, connector_key: Optional[str]) -> bool:
"""Whether a connection to this connector may be set up for write actions.
Args:
policies: ``connector_key`` to policy row, from :func:`load_policies`.
connector_key: The catalog key.
Returns:
False for a connector that offers no writes, or whose writes an admin
forbade.
"""
definition = catalog.get_definition(connector_key) if connector_key else None
if definition is None or not definition.mcp_write_url:
return False
return (policies.get(connector_key) or {}).get("allow_writes") is not False
def connector_enabled(conn, row: dict) -> bool:
"""Whether an admin left on the connector a connection row belongs to.
Args:
conn: Open database connection.
row: A ``connector_sessions`` row.
Returns:
False when the connector (or custom MCP servers) is turned off.
"""
return connector_is_enabled(load_policies(conn), catalog.connector_key_for_row(row))
def ensure_connector_allowed(conn, connector_key: Optional[str]) -> None:
"""Refuse a new connection to a connector an admin turned off.
Raises:
ConnectorDisabled: The connector, or custom MCP servers, are off.
"""
if not connector_key:
return
if not connector_is_enabled(load_policies(conn), connector_key):
raise ConnectorDisabled(f"{connector_key} is turned off by an admin")
def forced_credential_mode(conn, connector_key: Optional[str]) -> Optional[str]:
"""``owner`` or ``member`` when an admin forces one for this connector."""
if not connector_key:
return None
mode = (load_policies(conn).get(connector_key) or {}).get("credential_mode")
return mode if mode in ("owner", "member") else None