mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-20 02:16:39 +00:00
Connections own their credentials
Migration 0038 moves every stored secret (OAuth tokens, MCP OAuth tokens and client registrations, API keys) into the connection's encrypted envelope, links API-key tools to one connection per distinct credential, allows several accounts per provider, and adds credential_mode to sources and tools. OAuth MCP tools keep resolving each member's own token, as they did before. docsgpt.connectors.service is now the only reader of OAuth tokens: get_valid_token_info refreshes under a row lock and persists rotated refresh tokens, and a revoked grant flags the connection, pauses its sources and notifies the owner. Loaders build from a connection (BaseConnectorLoader.from_connection), so scheduled sync covers Drive, SharePoint and Confluence sources with no browser. S3 and Reddit keys stay on the connection instead of in remote_data. New endpoints: POST /api/connections, /setup, /reconnect, /picker-token, /claim, DELETE /api/connections/<id>, per-action permissions and MCP refresh-tools. Upload, file listing, sync and validate-session take a connection_id; session tokens keep working for this release. The tool executor reads credentials from the resolved connection (owner or member mode) and pauses on a Connect card when a connection needs signing in. docsgpt connectors reencrypt rewrites stored credentials after a key rotation.
This commit is contained in:
1 parent
667d4bbab0
commit
3511004f50
54 files changed
+4007
-754
No files matched your search
@@ -219,6 +219,8 @@ user_tools_table = Table(
|
||||
Column(
|
||||
"connection_id", UUID(as_uuid=True), ForeignKey("connector_sessions.id", ondelete="SET NULL"),
|
||||
),
|
||||
# Whose account a shared resource runs with: the owner's, or each member's.
|
||||
Column("credential_mode", Text, nullable=False, server_default="owner"),
|
||||
)
|
||||
|
||||
token_usage_table = Table(
|
||||
@@ -344,6 +346,8 @@ sources_table = Table(
|
||||
Column(
|
||||
"connection_id", UUID(as_uuid=True), ForeignKey("connector_sessions.id", ondelete="SET NULL"),
|
||||
),
|
||||
# Whose account a shared resource runs with: the owner's, or each member's.
|
||||
Column("credential_mode", Text, nullable=False, server_default="owner"),
|
||||
)
|
||||
|
||||
agents_table = Table(
|
||||
@@ -598,6 +602,12 @@ connector_sessions_table = Table(
|
||||
Column("account_label", Text),
|
||||
Column("auth_kind", Text),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
# Every secret of the connection, as one owner-bound v2 envelope.
|
||||
Column("encrypted_credentials", Text),
|
||||
Column("has_refresh_token", Boolean, nullable=False, server_default="false"),
|
||||
Column("scopes", JSONB, nullable=False, server_default="[]"),
|
||||
Column("last_error", Text),
|
||||
Column("last_used_at", DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,8 +9,13 @@ Shape notes:
|
||||
unique constraint on ``session_token``.
|
||||
* MCP sessions key off ``server_url`` instead — a single user may have
|
||||
multiple MCP servers, one row each. The composite unique index
|
||||
``(user_id, COALESCE(server_url, ''), provider)`` makes both patterns
|
||||
coexist without collision.
|
||||
``(user_id, provider, COALESCE(server_url, ''), COALESCE(account_label, ''))``
|
||||
makes both patterns coexist, and lets one user connect several accounts
|
||||
of the same provider (each row carries its own ``account_label``).
|
||||
* 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.
|
||||
* ``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``,
|
||||
@@ -31,8 +36,9 @@ from docsgpt.storage.db.serialization import PGNativeJSONEncoder
|
||||
_UPDATABLE_SCALARS = {
|
||||
"server_url", "session_token", "user_email", "status", "expires_at",
|
||||
"connector_key", "display_name", "account_label", "auth_kind",
|
||||
"encrypted_credentials", "has_refresh_token", "last_error", "last_used_at",
|
||||
}
|
||||
_UPDATABLE_JSONB = {"session_data", "token_info"}
|
||||
_UPDATABLE_JSONB = {"session_data", "token_info", "scopes"}
|
||||
|
||||
|
||||
def _jsonb(value: Any) -> Any:
|
||||
@@ -71,7 +77,8 @@ class ConnectorSessionsRepository:
|
||||
) -> dict:
|
||||
"""Insert or update a connector session row.
|
||||
|
||||
Conflict key is ``(user_id, COALESCE(server_url, ''), provider)``
|
||||
Conflict key is the account index
|
||||
``(user_id, provider, COALESCE(server_url, ''), COALESCE(account_label, ''))``
|
||||
so MCP rows (per-server) and OAuth rows (per-provider) both get
|
||||
idempotent upsert semantics.
|
||||
"""
|
||||
@@ -87,7 +94,7 @@ class ConnectorSessionsRepository:
|
||||
:status, CAST(:token_info AS jsonb),
|
||||
CAST(:session_data AS jsonb), :expires_at, :legacy_mongo_id
|
||||
)
|
||||
ON CONFLICT (user_id, COALESCE(server_url, ''), provider)
|
||||
ON CONFLICT (user_id, provider, COALESCE(server_url, ''), COALESCE(account_label, ''))
|
||||
DO UPDATE SET
|
||||
session_token = COALESCE(EXCLUDED.session_token, connector_sessions.session_token),
|
||||
user_email = COALESCE(EXCLUDED.user_email, connector_sessions.user_email),
|
||||
@@ -182,6 +189,77 @@ class ConnectorSessionsRepository:
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
provider: str,
|
||||
*,
|
||||
connector_key: str,
|
||||
auth_kind: str,
|
||||
display_name: Optional[str] = None,
|
||||
account_label: Optional[str] = None,
|
||||
server_url: Optional[str] = None,
|
||||
status: str = "connected",
|
||||
encrypted_credentials: Optional[str] = None,
|
||||
has_refresh_token: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""Insert a connection; return None when that account already exists."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO connector_sessions (
|
||||
user_id, provider, server_url, connector_key, auth_kind, display_name,
|
||||
account_label, status, encrypted_credentials, has_refresh_token, session_data
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :provider, :server_url, :connector_key, :auth_kind, :display_name,
|
||||
:account_label, :status, :encrypted_credentials, :has_refresh_token, '{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (user_id, provider, COALESCE(server_url, ''), COALESCE(account_label, ''))
|
||||
DO NOTHING
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"provider": provider,
|
||||
"server_url": server_url,
|
||||
"connector_key": connector_key,
|
||||
"auth_kind": auth_kind,
|
||||
"display_name": display_name,
|
||||
"account_label": account_label,
|
||||
"status": status,
|
||||
"encrypted_credentials": encrypted_credentials,
|
||||
"has_refresh_token": has_refresh_token,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def find_account(
|
||||
self, user_id: str, provider: str, *, server_url: Optional[str], account_label: Optional[str],
|
||||
) -> Optional[dict]:
|
||||
"""The connection for one account, matching the account unique index."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM connector_sessions WHERE user_id = :user_id AND provider = :provider "
|
||||
"AND COALESCE(server_url, '') = COALESCE(:server_url, '') "
|
||||
"AND COALESCE(account_label, '') = COALESCE(:account_label, '')"
|
||||
),
|
||||
{"user_id": user_id, "provider": provider, "server_url": server_url, "account_label": account_label},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def delete_by_id(self, connection_id: str) -> bool:
|
||||
"""Delete a connection row. Linked sources and tools keep existing (SET NULL)."""
|
||||
if not looks_like_uuid(connection_id):
|
||||
return False
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM connector_sessions WHERE id = CAST(:id AS uuid)"), {"id": str(connection_id)},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def get(self, connection_id: str) -> Optional[dict]:
|
||||
"""Fetch a connection by id, whoever owns it. Callers authorise."""
|
||||
if not looks_like_uuid(connection_id):
|
||||
@@ -237,7 +315,7 @@ class ConnectorSessionsRepository:
|
||||
"""Sources synced from a connection, newest first."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT id, name, type, date, sync_frequency, metadata, remote_data, user_id "
|
||||
"SELECT id, name, type, date, sync_frequency, metadata, remote_data, user_id, file_path "
|
||||
"FROM sources WHERE connection_id = CAST(:id AS uuid) ORDER BY date DESC"
|
||||
),
|
||||
{"id": str(connection_id)},
|
||||
@@ -337,7 +415,7 @@ class ConnectorSessionsRepository:
|
||||
|
||||
Notes:
|
||||
The conflict target matches the table's composite unique
|
||||
constraint ``(user_id, COALESCE(server_url, ''), provider)``
|
||||
index ``(user_id, provider, COALESCE(server_url, ''), COALESCE(account_label, ''))``
|
||||
so MCP's per-URL rows and OAuth's single-row-per-user rows
|
||||
both upsert idempotently.
|
||||
"""
|
||||
@@ -358,7 +436,7 @@ class ConnectorSessionsRepository:
|
||||
:user_id, :provider, :server_url,
|
||||
CAST(:patch AS jsonb)
|
||||
)
|
||||
ON CONFLICT (user_id, COALESCE(server_url, ''), provider)
|
||||
ON CONFLICT (user_id, provider, COALESCE(server_url, ''), COALESCE(account_label, ''))
|
||||
DO UPDATE SET
|
||||
server_url = COALESCE(EXCLUDED.server_url, connector_sessions.server_url),
|
||||
session_data =
|
||||
|
||||
@@ -23,8 +23,11 @@ from docsgpt.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
|
||||
|
||||
_JSONB_COLUMNS = {"config", "config_requirements", "actions"}
|
||||
_SCALAR_COLUMNS = {"name", "custom_name", "display_name", "description", "status"}
|
||||
_ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS
|
||||
_SCALAR_COLUMNS = {"name", "custom_name", "display_name", "description", "status", "credential_mode"}
|
||||
# Set by server code only (tool creation, connection setup); route handlers
|
||||
# must not pass client input here.
|
||||
_UUID_COLUMNS = {"connection_id"}
|
||||
_ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS | _UUID_COLUMNS
|
||||
|
||||
|
||||
def _encode_jsonb(value: Any) -> Any:
|
||||
@@ -60,6 +63,8 @@ class UserToolsRepository:
|
||||
status: bool = True,
|
||||
extra: Optional[dict] = None,
|
||||
legacy_mongo_id: Optional[str] = None,
|
||||
connection_id: Optional[str] = None,
|
||||
credential_mode: str = "owner",
|
||||
) -> dict:
|
||||
"""Insert a new tool row. ``extra`` is merged into the config JSONB."""
|
||||
cfg = config or {}
|
||||
@@ -70,14 +75,16 @@ class UserToolsRepository:
|
||||
"""
|
||||
INSERT INTO user_tools (
|
||||
user_id, name, custom_name, display_name, description,
|
||||
config, config_requirements, actions, status, legacy_mongo_id
|
||||
config, config_requirements, actions, status, legacy_mongo_id,
|
||||
connection_id, credential_mode
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :name, :custom_name, :display_name, :description,
|
||||
CAST(:config AS jsonb),
|
||||
CAST(:config_requirements AS jsonb),
|
||||
CAST(:actions AS jsonb),
|
||||
:status, :legacy_mongo_id
|
||||
:status, :legacy_mongo_id,
|
||||
CAST(:connection_id AS uuid), :credential_mode
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
@@ -93,6 +100,8 @@ class UserToolsRepository:
|
||||
"actions": _encode_jsonb(actions or []),
|
||||
"status": status,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
"connection_id": str(connection_id) if connection_id else None,
|
||||
"credential_mode": credential_mode,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
@@ -213,6 +222,9 @@ class UserToolsRepository:
|
||||
if col in _JSONB_COLUMNS:
|
||||
set_clauses.append(f"{col} = CAST(:{col} AS jsonb)")
|
||||
params[col] = _encode_jsonb(val)
|
||||
elif col in _UUID_COLUMNS:
|
||||
set_clauses.append(f"{col} = CAST(:{col} AS uuid)")
|
||||
params[col] = str(val) if val else None
|
||||
else:
|
||||
set_clauses.append(f"{col} = :{col}")
|
||||
params[col] = val
|
||||
|
||||
Reference in new issue
Block a user