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:
arc53-machine committed 2026-09-28 17:03:09 +01:00
1 parent 667d4bbab0
commit 3511004f50
54 files changed
+4007 -754

No files matched your search

+10
View File
@@ -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 =
+16 -4
View File
@@ -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