mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-16 16:15:06 +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
+143
-11
@@ -1237,6 +1237,7 @@ def remote_worker(
|
||||
config=None,
|
||||
idempotency_key=None,
|
||||
source_id=None,
|
||||
connection_id=None,
|
||||
):
|
||||
safe_user = safe_filename(user)
|
||||
full_path = os.path.join(directory, safe_user, uuid.uuid4().hex)
|
||||
@@ -1283,7 +1284,14 @@ def remote_worker(
|
||||
self.update_state(state="PROGRESS", meta={"current": 1})
|
||||
logging.info("Initializing remote loader with type: %s", loader)
|
||||
remote_loader = RemoteCreator.create_loader(loader)
|
||||
raw_docs = remote_loader.load_data(source_data)
|
||||
loader_input = source_data
|
||||
if connection_id:
|
||||
loader_input = _with_connection_credentials(source_data, connection_id)
|
||||
if loader_input is None:
|
||||
from docsgpt.connectors.service import ConnectionUnavailable
|
||||
|
||||
raise ConnectionUnavailable("Reconnect to continue", connection_id=str(connection_id))
|
||||
raw_docs = remote_loader.load_data(loader_input)
|
||||
|
||||
cfg = SourceConfig.parse(config)
|
||||
chunker = ChunkerCreator.create_chunker(
|
||||
@@ -1414,6 +1422,8 @@ def remote_worker(
|
||||
f"Failed to update last_sync for source {source_id_for_events}: {upd_err}"
|
||||
)
|
||||
upload_index(full_path, file_data)
|
||||
if connection_id:
|
||||
_link_source_to_connection(source_id_for_events, str(connection_id))
|
||||
publish_user_event(
|
||||
user,
|
||||
"source.ingest.completed",
|
||||
@@ -1467,6 +1477,7 @@ def sync(
|
||||
retriever,
|
||||
doc_id=None,
|
||||
directory="temp",
|
||||
connection_id=None,
|
||||
):
|
||||
try:
|
||||
remote_worker(
|
||||
@@ -1480,6 +1491,7 @@ def sync(
|
||||
sync_frequency,
|
||||
"sync",
|
||||
doc_id,
|
||||
connection_id=connection_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error during sync: {e}", exc_info=True)
|
||||
@@ -1494,7 +1506,7 @@ def sync_worker(self, frequency):
|
||||
with db_readonly() as conn:
|
||||
result = conn.execute(
|
||||
sql_text(
|
||||
"SELECT id, name, user_id, type, remote_data, retriever "
|
||||
"SELECT id, name, user_id, type, remote_data, retriever, connection_id "
|
||||
"FROM sources WHERE sync_frequency = :freq"
|
||||
),
|
||||
{"freq": frequency},
|
||||
@@ -1511,10 +1523,17 @@ def sync_worker(self, frequency):
|
||||
|
||||
sync_counts["total_sync_count"] += 1
|
||||
|
||||
# Connector sources have no RemoteCreator loader and need an OAuth
|
||||
# token to sync, which a scheduled task lacks — skip them.
|
||||
# Connector sources sync from their connection, whose token the
|
||||
# worker can refresh. Legacy ones with no connection still need the
|
||||
# browser, so they are skipped as before.
|
||||
if source_type and source_type.startswith("connector"):
|
||||
sync_counts["sync_skipped"] += 1
|
||||
if doc.get("connection_id"):
|
||||
from docsgpt.api.user.tasks import sync_connector_source as sync_task
|
||||
|
||||
sync_task.delay(doc_id)
|
||||
sync_counts["sync_dispatched"] += 1
|
||||
else:
|
||||
sync_counts["sync_skipped"] += 1
|
||||
continue
|
||||
|
||||
source_data = normalize_remote_data(source_type, doc.get("remote_data"))
|
||||
@@ -1525,7 +1544,8 @@ def sync_worker(self, frequency):
|
||||
continue
|
||||
|
||||
resp = sync(
|
||||
self, source_data, name, user, source_type, frequency, retriever, doc_id
|
||||
self, source_data, name, user, source_type, frequency, retriever, doc_id,
|
||||
connection_id=str(doc["connection_id"]) if doc.get("connection_id") else None,
|
||||
)
|
||||
sync_counts[
|
||||
"sync_success" if resp["status"] == "success" else "sync_failure"
|
||||
@@ -1533,7 +1553,7 @@ def sync_worker(self, frequency):
|
||||
return {
|
||||
key: sync_counts[key]
|
||||
for key in [
|
||||
"total_sync_count", "sync_success", "sync_failure", "sync_skipped",
|
||||
"total_sync_count", "sync_success", "sync_failure", "sync_skipped", "sync_dispatched",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2161,6 +2181,113 @@ def _webhook_tool_allowlist(agent_config):
|
||||
return []
|
||||
|
||||
|
||||
def _with_connection_credentials(source_data, connection_id: str):
|
||||
"""Loader input with the connection's stored keys merged in, or None.
|
||||
|
||||
S3 and Reddit sources made from a connection keep their keys on the
|
||||
connection only, never in ``sources.remote_data``. Returns None when the
|
||||
connection is gone or needs reconnecting.
|
||||
"""
|
||||
from docsgpt.connectors import service
|
||||
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
|
||||
|
||||
with db_readonly() as conn:
|
||||
row = ConnectorSessionsRepository(conn).get(str(connection_id))
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
credentials = service.get_credentials(row)
|
||||
except service.ConnectionUnavailable:
|
||||
return None
|
||||
as_text = isinstance(source_data, str)
|
||||
data = json.loads(source_data) if as_text else dict(source_data or {})
|
||||
data.update(credentials)
|
||||
return json.dumps(data) if as_text else data
|
||||
|
||||
|
||||
def _link_source_to_connection(source_id: str, connection_id: str) -> None:
|
||||
"""Point a source at the connection it syncs from, and lift any reconnect pause."""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
conn.execute(
|
||||
sql_text(
|
||||
"UPDATE sources SET connection_id = CAST(:cid AS uuid), "
|
||||
"metadata = metadata - 'sync_state' WHERE id = CAST(:sid AS uuid)"
|
||||
),
|
||||
{"cid": str(connection_id), "sid": str(source_id)},
|
||||
)
|
||||
except Exception:
|
||||
logging.warning("Could not link source %s to connection %s", source_id, connection_id, exc_info=True)
|
||||
|
||||
|
||||
def sync_connector_source(self, source_id: str) -> Dict[str, Any]:
|
||||
"""Re-download and re-index a connector source from its connection.
|
||||
|
||||
Runs as the connection owner with no browser: the connection service
|
||||
refreshes the token under a row lock. When the grant was revoked the
|
||||
service flags the connection and pauses its sources, and this returns
|
||||
``paused`` rather than failing again on every schedule.
|
||||
|
||||
Args:
|
||||
self: The bound Celery task.
|
||||
source_id: The source to sync.
|
||||
|
||||
Returns:
|
||||
``{"status": "success" | "paused" | "skipped"}`` plus the ingest result.
|
||||
"""
|
||||
from docsgpt.connectors.service import ConnectionUnavailable, normalize_status
|
||||
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
|
||||
|
||||
with db_readonly() as conn:
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
row = conn.execute(
|
||||
sql_text(
|
||||
"SELECT id, name, user_id, remote_data, retriever, sync_frequency, config, connection_id "
|
||||
"FROM sources WHERE id = CAST(:id AS uuid)"
|
||||
),
|
||||
{"id": str(source_id)},
|
||||
).fetchone()
|
||||
source = dict(row._mapping) if row else None
|
||||
connection = (
|
||||
ConnectorSessionsRepository(conn).get(str(source["connection_id"]))
|
||||
if source and source.get("connection_id")
|
||||
else None
|
||||
)
|
||||
if not source or not connection:
|
||||
return {"status": "skipped"}
|
||||
if normalize_status(connection) != "connected":
|
||||
return {"status": "paused"}
|
||||
remote_data = source.get("remote_data") or {}
|
||||
if isinstance(remote_data, str):
|
||||
try:
|
||||
remote_data = json.loads(remote_data)
|
||||
except json.JSONDecodeError:
|
||||
remote_data = {}
|
||||
provider = remote_data.get("provider") or connection.get("provider")
|
||||
try:
|
||||
result = ingest_connector(
|
||||
self,
|
||||
source.get("name"),
|
||||
source.get("user_id"),
|
||||
provider,
|
||||
connection_id=str(connection["id"]),
|
||||
file_ids=remote_data.get("file_ids") or [],
|
||||
folder_ids=remote_data.get("folder_ids") or [],
|
||||
recursive=remote_data.get("recursive", True),
|
||||
retriever=source.get("retriever") or "classic",
|
||||
operation_mode="sync",
|
||||
doc_id=str(source["id"]),
|
||||
sync_frequency=source.get("sync_frequency") or "never",
|
||||
config=source.get("config") or None,
|
||||
)
|
||||
except ConnectionUnavailable:
|
||||
return {"status": "paused"}
|
||||
return {"status": "success", "result": result}
|
||||
|
||||
|
||||
def ingest_connector(
|
||||
self,
|
||||
job_name: str,
|
||||
@@ -2177,6 +2304,7 @@ def ingest_connector(
|
||||
config=None,
|
||||
idempotency_key=None,
|
||||
source_id=None,
|
||||
connection_id=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ingestion for internal knowledge bases (GoogleDrive, etc.).
|
||||
@@ -2185,7 +2313,8 @@ def ingest_connector(
|
||||
job_name: Name of the ingestion job
|
||||
user: User identifier
|
||||
source_type: Type of remote source ("google_drive", "dropbox", etc.)
|
||||
session_token: Authentication token for the service
|
||||
session_token: Legacy browser session token naming the connection
|
||||
connection_id: The connection whose account the files are read with
|
||||
file_ids: List of file IDs to download
|
||||
folder_ids: List of folder IDs to download
|
||||
recursive: Whether to recursively download folders
|
||||
@@ -2247,8 +2376,8 @@ def ingest_connector(
|
||||
meta={"current": 10, "status": "Initializing connector"},
|
||||
)
|
||||
|
||||
if not session_token:
|
||||
raise ValueError(f"{source_type} connector requires session_token")
|
||||
if not session_token and not connection_id:
|
||||
raise ValueError(f"{source_type} connector requires a connection")
|
||||
|
||||
if not ConnectorCreator.is_supported(source_type):
|
||||
raise ValueError(
|
||||
@@ -2256,8 +2385,9 @@ def ingest_connector(
|
||||
)
|
||||
|
||||
remote_loader = ConnectorCreator.create_connector(
|
||||
source_type, session_token
|
||||
source_type, session_token, connection_id=connection_id
|
||||
)
|
||||
connection_id = remote_loader.connection_id
|
||||
|
||||
# Create a clean config for storage
|
||||
api_source_config = {
|
||||
@@ -2411,6 +2541,8 @@ def ingest_connector(
|
||||
)
|
||||
|
||||
upload_index(vector_store_path, file_data)
|
||||
if connection_id:
|
||||
_link_source_to_connection(source_id_for_events, connection_id)
|
||||
|
||||
# Ensure we mark the task as complete
|
||||
self.update_state(
|
||||
|
||||
Reference in new issue
Block a user