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

+24 -2
View File
@@ -79,6 +79,12 @@ DURABLE_TASK = dict(
)
def _connection_unavailable():
from docsgpt.connectors.service import ConnectionUnavailable
return ConnectionUnavailable
def durable_task(**overrides) -> Dict:
"""Return ``DURABLE_TASK`` with per-task overrides applied.
@@ -167,13 +173,16 @@ def ingest(
@with_idempotency(task_name="ingest_remote", on_poison=_emit_ingest_poison_event)
def ingest_remote(
self, source_data, job_name, user, loader,
config=None, idempotency_key=None, source_id=None,
config=None, idempotency_key=None, source_id=None, connection_id=None,
sync_frequency="never",
):
resp = remote_worker(
self, source_data, job_name, user, loader,
sync_frequency=sync_frequency,
config=config,
idempotency_key=idempotency_key,
source_id=source_id,
connection_id=connection_id,
)
return resp
@@ -249,6 +258,15 @@ def schedule_syncs(self, frequency):
return resp
@celery.task(bind=True, acks_late=True, autoretry_for=(Exception,), max_retries=3, retry_backoff=60,
dont_autoretry_for=(_connection_unavailable(),))
def sync_connector_source(self, source_id):
"""Re-sync one connector source from its connection, with no browser involved."""
from docsgpt.worker import sync_connector_source as run
return run(self, source_id)
@celery.task(bind=True)
def sync_source(
self,
@@ -408,7 +426,9 @@ except Exception:
pass
@celery.task(**DURABLE_TASK)
# A revoked or disconnected connection will not heal by retrying; the
# service has already paused the source and told its owner to reconnect.
@celery.task(**durable_task(dont_autoretry_for=(DocumentParseError, _connection_unavailable())))
@with_idempotency(
task_name="ingest_connector_task", on_poison=_emit_ingest_poison_event,
)
@@ -428,6 +448,7 @@ def ingest_connector_task(
config=None,
idempotency_key=None,
source_id=None,
connection_id=None,
):
from docsgpt.worker import ingest_connector
@@ -437,6 +458,7 @@ def ingest_connector_task(
user,
source_type,
session_token=session_token,
connection_id=connection_id,
file_ids=file_ids,
folder_ids=folder_ids,
recursive=recursive,