Skip to content

Connector System

The connector system provides a plugin architecture for integrating external services (Canvas LMS, Outlook Mail, Outlook Calendar, OneDrive, SharePoint, Gmail, Google Calendar, Google Drive, PostgreSQL, ServiceNow, etc.) into CampusCore. It handles OAuth2 flows, encrypted credential storage, and a decorator-based action system that makes connectors self-describing.

Directory Structure

apps/connectors/
├── connectors/              # One subpackage per connector integration
│   ├── base.py              # BaseConnector abstract class
│   ├── registry.py          # Auto-discovery registry
│   ├── canvas/              # Canvas LMS connector
│   │   ├── __init__.py      #   re-exports CanvasConnector for discovery
│   │   ├── connector.py     #   CanvasConnector (OAuth flow, get_client)
│   │   ├── actions/                 # CanvasActions, composed from audience mixin modules
│   │   │   ├── __init__.py          #   composes + re-exports CanvasActions
│   │   │   ├── base.py              #   CanvasActionsBase (client closure constructor)
│   │   │   ├── shared_reads.py      #   reads any enrolled user can make
│   │   │   ├── instructor_reads.py  #   teacher/TA reads
│   │   │   ├── student_writes.py    #   a student's own-work writes
│   │   │   ├── instructor_writes.py #   teaching writes
│   │   │   └── admin.py             #   account search reads + enrollment writes
│   │   ├── models.py                # Pydantic output models
│   │   ├── transport.py             # pagination, origin rewrite, file download, GraphQL
│   │   ├── reshapes.py              # upstream-row -> output-model reshape fns
│   │   └── grade_projection.py      # grade projection math
│   ├── google_workspace/    # Google product connectors (Gmail, Google Calendar, Google Drive)
│   │   ├── __init__.py      #   re-exports the three connector classes for discovery
│   │   ├── connector.py     #   shared Google OAuth base + the three product classes
│   │   ├── actions/                # one composed actions class per product
│   │   │   ├── __init__.py         #   composes + re-exports the three actions classes
│   │   │   ├── base.py             #   GoogleWorkspaceActionsBase (client closure constructor)
│   │   │   ├── identity.py         #   per-product get_user_info probes + the Gmail whoami cache
│   │   │   ├── mail_reads.py       #   search/read mail, open-loop views, attachment bridge
│   │   │   ├── mail_writes.py      #   drafts, send, organize (all user_confirm)
│   │   │   ├── calendar_reads.py   #   events, free/busy, slot search, conflicts
│   │   │   ├── calendar_writes.py  #   create/update/cancel/respond (all user_confirm)
│   │   │   ├── drive_reads.py      #   Drive browse/search/recent/sharing + import contract
│   │   │   └── drive_writes.py     #   documents, organize, share, trash (all user_confirm)
│   │   ├── models.py               # Pydantic output models
│   │   ├── mime.py                 # RFC 2822 draft builders (reply/forward/new)
│   │   ├── slot_search.py          # free-slot computation over merged busy windows
│   │   ├── transport.py            # pageToken pagination over the Gmail/Calendar/Drive bases
│   │   └── reshapes.py             # upstream-row -> output-model reshape fns
│   ├── microsoft_365/       # Microsoft product connectors (Outlook Mail, Outlook Calendar, OneDrive, SharePoint)
│   │   ├── __init__.py      #   re-exports the four connector classes for discovery
│   │   ├── connector.py     #   shared Entra OAuth base + the four product classes
│   │   ├── actions/                # one composed actions class per product
│   │   │   ├── __init__.py         #   composes + re-exports the four actions classes
│   │   │   ├── base.py             #   Microsoft365ActionsBase (client closure constructor)
│   │   │   ├── identity.py         #   get_user_info probe + whoami cache, in every product
│   │   │   ├── mail_reads.py       #   search/read mail, open-loop views, attachment bridge
│   │   │   ├── mail_writes.py      #   drafts, send, organize (all user_confirm)
│   │   │   ├── calendar_reads.py   #   events, free/busy, findMeetingTimes, conflicts
│   │   │   ├── calendar_writes.py  #   create/update/cancel/respond (all user_confirm)
│   │   │   ├── drive_reads.py      #   OneDrive browse/search/recent/sharing + import contract
│   │   │   ├── drive_writes.py     #   documents, organize, share, trash (all user_confirm)
│   │   │   └── sharepoint_reads.py #   SharePoint sites/libraries/search over id tokens + import contract
│   │   ├── models.py               # Pydantic output models
│   │   ├── transport.py            # @odata.nextLink pagination with host pinning
│   │   └── reshapes.py             # upstream-row -> output-model reshape fns
│   ├── postgres/            # PostgreSQL connector
│   │   ├── __init__.py
│   │   ├── connector.py
│   │   └── actions.py
│   ├── s3_bucket/           # Amazon S3 Bucket connector
│   │   ├── __init__.py
│   │   ├── connector.py
│   │   ├── actions.py
│   │   └── helpers.py       #   boto3 client builder, MIME + key utils
│   ├── servicenow/          # ServiceNow Table API connector
│   │   ├── __init__.py
│   │   ├── connector.py     #   OAuth flow, get_client closure
│   │   ├── actions.py       #   10 @action methods + validation constants
│   │   ├── models.py        #   Pydantic output models (IncidentSummary, etc.)
│   │   ├── helpers.py       #   reshape fns, URL builders, query builder
│   │   └── IMPLEMENTATION_PLAN.md
│   └── banner/              # Planned (doc only - no code yet)
│       └── IMPLEMENTATION_PLAN.md
├── actions/                 # Decorator-based action system
│   ├── decorator.py         # @action() decorator and ActionMeta
│   └── introspect.py        # Schema introspection (get_action_schemas, has_tag)
├── errors.py                # ConnectorError taxonomy + boundary translator
├── health.py                # record_connection_failure (auth errors → Connection status)
├── services/                # Shared services
│   ├── secrets.py           # Fernet encryption/decryption
│   ├── oauth.py             # OAuth2 session builder (authlib)
│   ├── http.py              # httpx client with tenacity retry
│   ├── token_updater.py     # Reusable OAuth2 token refresh callback
│   ├── audit.py             # Action audit logging
│   └── rate_limit.py        # Cache-based rate limiting
├── models.py                # Connector, ConnectorConfig, Connection, ConnectorAuditLog
├── views.py                 # OAuth2 browser-redirect endpoints (start/callback)
├── urls.py                  # URL routing
├── admin.py                 # Django admin configuration
└── tests/                   # Test suite

Core Concepts

Three-Model Pattern

Model Purpose
Connector Catalog entry (slug, name, kind, icon, is_enabled). One per integration type. kind is ConnectorKind - oauth2 or manual - typed on the model, checked by a database constraint, and carried to the SPA as an enum rather than a boolean.
ConnectorConfig Admin-managed key-value config (CLIENT_ID, CLIENT_SECRET, etc.). Secret values are Fernet-encrypted on save.
Connection A user's authenticated link to a connector. Stores encrypted OAuth tokens or manual credentials. Tracks status and last_used_at.

Every ConnectorConfig row is scoped to exactly one connector - there is no shared storage tier. Vendor families (products that can authenticate against one shared OAuth application) share credentials only through explicit copy links: an admin consents on the origin connector (Connector.offers_config_copy), a sibling copies from it, and a ConnectorConfigLink row (one origin per copier, enforced by a OneToOne on copier) records the relationship. A connector opts into the family mechanics with two class attributes:

class Microsoft365ConnectorBase(BaseConnector):
    config_family = "microsoft"
    copyable_config_keys = frozenset({"CLIENT_ID", "CLIENT_SECRET", "TENANT_ID"})

config_family names the sibling group - who may be offered a copy, and whose connections are revocation candidates. copyable_config_keys names what a copy carries; every other key (REDIRECT_URI, SCOPES) is genuinely per-connector and stays editable during and after a copy. copyable_config_keys must be a subset of the keys workspace_config_schema() declares - the connector contract test pins this.

Copying duplicates the origin's copyable rows onto the copier ciphertext-verbatim (ConnectorConfig.save() passes an already-encrypted value through untouched); the link row is provenance plus propagation routing, never a read-through. While a link stands, the copier's copyable rows are rewritten only by the link operations: an origin edit prompts the admin to propagate to all linked copiers or detach them, and a copier edits locally only after breaking its link. Links never chain - a copier cannot be an origin and an origin cannot itself be a copier. Declining or revoking consent only stops new copy offers; standing links keep working until broken. apps/connectors/services/connector_config.py is the single owner of the storage rule - read_value, write_value, copy_config, break_link, propagate_config - and every application reader and writer goes through it, including the workspace API and BaseConnector.get_config_value. The three link operations are audited (connector_config_copied, connector_config_link_broken, connector_config_propagated - see audit-logging.md).

Break-glass caveat: the Django admin still allows raw edits of ConnectorConfig rows - both through the standalone ConnectorConfigAdmin and through the Connector inline - which bypasses the link protections and can silently diverge a linked copier from its origin. ConnectorConfigLink itself is read-only in the Django admin; links are created and broken only through Settings -> Workspace Connectors so they carry the audit trail.

BaseConnector

Every connector extends BaseConnector and must define:

class MyConnector(BaseConnector):
    slug = "my-service"          # Unique identifier, matches Connector.slug in DB
    kind = ConnectorKind.OAUTH2  # or ConnectorKind.MANUAL
    display_name = "My Service"
    icon_url = "https://..."     # Optional
    actions_class = MyActions    # Class with @action-decorated methods

Required methods for subclasses: - connection_schema() - JSON Schema for manual credential forms (return None for OAuth2) - start_connect(request) - Begin OAuth2 flow, return {"redirect": url} - handle_callback(request) - Handle OAuth2 callback, return credentials dict - test_and_normalize_manual(form_data) - Validate manual credentials - get_client(credentials, token_updater) - Build authenticated API client

Optional capability hooks, where support is signaled through the return value rather than by inspecting the class: - prepare_s3_import(connection, file_id) - Return the source S3 location for a server-side copy import, or None (the default) to fall back to download-and-reupload - revoke_token(connection) - Revoke the connection's token upstream on disconnect; return "revoked" after revoking, "unsupported" (the default) when there is nothing to revoke, "deferred" when a sibling still needs the grant, and raise on failure. Implemented by the google_workspace products (Google's revoke endpoint kills the whole (user, client id) grant, so revocation defers while the user holds a sibling Google connection whose connector stores the same decrypted CLIENT_ID - a divergent or unreadable sibling client id runs on its own grant and never defers) and canvas (DELETE /login/oauth2/token through the refreshing session)

Authorization scoping

Connectors fall into two authorization models, and the model determines how much enforcement work CampusCore has to do itself. See connectors-philosophy.md §13 for the full discussion; this table is the quick reference.

Connector Model How scoping is enforced
Canvas upstream-scoped Per-user OAuth2 token stored on Connection.credentials; Canvas enforces "Alice can only see Alice's courses"
Gmail / Google Calendar / Google Drive upstream-scoped Per-user OAuth2 token, one per product; Gmail, Google Calendar and Google Drive enforce mailbox, calendar and file scoping
Outlook Mail / Outlook Calendar / OneDrive / SharePoint upstream-scoped Per-user OAuth2 token via Microsoft Graph delegated permissions; Exchange Online, OneDrive and SharePoint enforce mailbox, calendar, file and site scoping
PostgreSQL locally-scoped (user-supplied) Each user enters their own DB credentials; upstream DB grants enforce scoping
S3 Bucket locally-scoped (user-supplied) Each user enters their own AWS credentials or IAM role; AWS enforces scoping
ServiceNow upstream-scoped Per-user OAuth2 token via the ServiceNow OAuth Application Registry; native ACLs (itil, knowledge, table-level rules) enforce read and write scoping. See the ServiceNow setup guide
Banner (Ethos) - planned locally-scoped (institution-wide) Institution-level ETHOS_API_KEY shared across users; CampusCore must enforce scoping via a BannerAuthorizationMiddleware before the action runs

Upstream-scoped connectors require no custom authorization logic - the vendor does the enforcement. Locally-scoped connectors split into two sub-flavors: user-supplied (each user brings their own credentials, so upstream scoping still works but at the connection level instead of the OAuth level) and institution-wide (one service-account credential is shared across all users, so CampusCore must enforce identity mapping + scoping middleware itself). The institution-wide flavor is the highest-responsibility: it requires an explicit BannerAuthorizationMiddleware that enforces identity mapping and scoping before every action runs.

Before adding a new connector, always decide which model it uses and document the choice in the connector's class docstring. The connector assistant skill will prompt for this explicitly.

Note on ServiceNow

Connectors philosophy §2 names ServiceNow as the one sanctioned exception for reaching for a vendor MCP server instead of hand-rolling. The v1 connector ships as kind="oauth2" Table API anyway - a deliberate, documented deviation to avoid greenfield platform work on kind="mcp" infrastructure. The full rationale lives in the connector's class docstring. Revisit once kind="mcp" transport lands.

Microsoft product connectors

The microsoft_365 package ships four connectors - outlook-mail, outlook-calendar, onedrive, and sharepoint - over one Entra app registration (family microsoft), replacing the former microsoft-365 suite connector. Each product runs its own OAuth flow with only its product's scopes, so a mail token cannot touch files; admins enable, test, and govern each product independently. Every call runs on the connected user's own delegated Graph token, so Exchange Online, OneDrive, and SharePoint enforce all scoping. Every write is user_confirm and ships policy-disabled per role. Action names and their capability tags carry the product - search_outlook_messages under outlook-mail, create_outlook_calendar_event under outlook-calendar, list_onedrive_files under onedrive - so the governance screen and confirmation cards read unambiguously. get_user_info stays product-neutral and is composed into every product from the IdentityReads mixin: it is the account-level probe the connection Test button executes on every oauth2 connector. list_onedrive_files carries the file_browse tag and download_onedrive_file the file_download tag - the pair the cloud-import picker discovers and resolves by tag. Both return plain dicts because the import pipeline consumes them with dict.get. download_onedrive_file refuses agent-context calls and points to load_onedrive_file_into_chat, the bounded read-into-chat door. Two curated open-loop reads (list_outlook_threads_awaiting_my_reply, list_outlook_sent_awaiting_response) merge inbox and Sent Items pages and group by conversationId - they are candidate lists for triage, not judgments. Mail attachments flow into chat through the standard chat_file_ingestor bridge with the metadata read as the access proof. Pagination follows @odata.nextLink with the host pinned to graph.microsoft.com/v1.0 (a foreign link is refused, never followed).

sharepoint is read-only (Sites.Read.All; SharePoint enforces the connecting user's own site permissions per call) and folds the site -> document library -> folder hierarchy into a prefixed id-token space, because both file-surface consumers pass a single opaque id: site:{site-id} and lib:{drive-id} render as pseudo-folders, and item:{drive-id}:{item-id} names a folder or file (the drive id percent-encoded so the parse is total). An unknown or foreign id raises ConnectorInvalidRequestError instead of reaching Graph, and the token is a persisted wire format - it keys ConnectorFileCopy.upstream_file_id - so changing the encoding needs a migration; golden-literal tests pin it. Its browse pair is list_sharepoint_files/download_sharepoint_file; search is library-scoped (/drives/{id}/root/search) rather than tenant-wide, keeping the product boundary intact. The sites tier returns one deterministic page of at most ~200 name-sorted sites (the picker UI cannot page), so a tenant beyond that bound reaches missing sites by search, not scrolling. Setup lives in microsoft-connectors-setup.md.

Google product connectors

The google_workspace package ships three connectors - gmail, google-calendar, and google-drive - over one Google Cloud OAuth client (family google), replacing the former google-workspace suite connector. They mirror the Microsoft product action vocabulary so the agent's habits transfer between the two. The reborn google-drive carries the cloud-import browse/download contract as list_google_drive_files/download_google_drive_file, resolved by the file_browse/file_download tags. Every call runs on the connected user's own delegated Google token under that product's scope alone (gmail.modify, calendar, or drive), so Gmail, Calendar and Drive enforce all scoping; gmail.modify deliberately excludes permanent deletion, and the file surface trashes, never deletes. Each product's identity probe (get_user_info, the connection Test) stays inside its own scope: Gmail reads its profile, Calendar reads the primary calendar, Drive reads its about user. Every write is user_confirm and ships policy-disabled per role. Each connector pins its own api_version (Gmail v1, Calendar v3, Drive v3); the client closure refuses any URL outside the product's own bases, so a gmail token cannot even be asked to call Drive. Drive reports several non-auth failures as 403 (a too-large export, a policy-blocked share, rate limits), which the boundary classifier would read as an auth failure and mark the connection expired - the closure translates the known reasons before they reach it (transport.translate_drive_403). Google-native files (Docs, Sheets, Slides) have no downloadable bytes; they export as PDF, and the stored filename gains a .pdf suffix because the chat-file host refuses extensions it does not know. Drive omits nextPageToken from a response whose fields parameter does not name it, so every listing's fields includes it - dropping that silently caps results at one page. Drafts are built locally as RFC 2822 messages (mime.py) and posted base64url-encoded with threadId nested in the message resource - that nesting is what makes a reply land on its thread. Gmail listings return ids only, so every listed item costs its own metadata GET; the curated views cap at 15 threads to stay inside the deployed ALB's 180s idle timeout. Action names and their capability tags carry the product here too - search_gmail_messages under gmail, create_google_calendar_event under google-calendar, list_google_drive_files under google-drive - with get_user_info staying product-neutral as the connection Test probe. Calendar has no native meeting-time finder, so find_google_calendar_meeting_slots computes free slots locally (slot_search.py) from freeBusy responses, reporting attendees whose availability could not be checked instead of silently assuming them free. events.patch replaces array fields wholesale, so respond_to_google_calendar_invitation PATCHes the full fetched attendee array with only the user's own entry changed - a payload carrying just that entry would wipe every other attendee. Setup lives in google-connectors-setup.md.

Auto-Discovery Registry

Connectors are automatically discovered on import. Any .py file in connectors/connectors/ that defines a BaseConnector subclass with a non-empty slug is registered. No manual imports needed.

from apps.connectors.connectors.registry import get_connector, list_connectors

adapter = get_connector("google-drive")   # Get a specific connector
all_connectors = list_connectors()        # Get all registered connectors

Action System

@action Decorator

Actions are defined as decorated methods on an Actions class. The decorator captures tags for capability discovery. Type hints and docstrings provide schema information.

Schema discovery walks the class's method resolution order, so a connector whose surface outgrows one module can split its actions into mixin modules composed into a single actions class. The Canvas actions/ package is the reference shape: one mixin module per stakeholder audience over a shared constructor base. Smaller connectors keep a single actions.py.

from apps.connectors.actions import action

class MyActions:
    def __init__(self, client):
        self.client = client

    @action("file_browse")
    def list_files(self, folder_id: str | None = None) -> dict:
        """List files and folders."""
        ...

    @action("file_download")
    def download_file(self, file_id: str) -> dict:
        """Download a file by ID."""
        ...

Tags

Tags are used for capability discovery without isinstance checks:

Tag Meaning
file_browse Connector can list files/folders (used by cloud import UI)
file_download Connector can download files (used by import pipeline)
read General read operations
from apps.connectors.actions.introspect import has_tag, get_action_schemas

# Check if a connector supports file browsing
if has_tag(adapter.actions_class, "file_browse"):
    ...

# Get all action schemas for a connector
schemas = get_action_schemas(adapter.actions_class)

Executing Actions

# One front door: a live Connection, an action name, params.
result = adapter.execute_action(connection, "list_files", {"folder_id": "root"})

# The agent bridge passes its per-run ActionContext so clients are cached
# across the run's action calls:
result = adapter.execute_action(connection, "list_files", params, ctx=ctx)

Credential decryption and token-updater wiring happen inside the door (_get_or_build_client); callers never handle raw credentials.

Cloud Drive Import Pipeline

When a user imports files from Google Drive, OneDrive, or SharePoint into a knowledge folder (SharePoint's picker tree starts at sites and document libraries, encoded as pseudo-folders in the same browse contract):

User selects files in browser UI
    → POST to cloud_import_submit (HTMX endpoint)
    → ConnectorImportService.import_files()
        → adapter.execute_action(<file_download-tagged action>, ...)  # Downloads file bytes
        → Upload bytes to S3 (same key pattern as direct uploads)
        → Create SourceFile record with status='uploaded'
        → Send SQS message via QueueService
        → Document processing pipeline handles the rest

The import service reuses the existing document processing pipeline - imported files are indistinguishable from directly uploaded files after the initial download.

HTMX Endpoints

Endpoint Purpose
GET /api/htmx/knowledge-folders/<folder_id>/cloud-import/ List all cloud drive connectors with Connect/Browse buttons
GET /api/htmx/knowledge-folders/<folder_id>/cloud-import/<connection_id>/browse/ Browse files in a connected drive
POST /api/htmx/knowledge-folders/<folder_id>/cloud-import/<connection_id>/import/ Import selected files

Import Failure Semantics

An ImportBatch keeps one error_details entry per failed file, uncapped, and error_count is that list's length rather than a stored counter that can drift from it. A failure that kills the whole batch is not a file failure, so its reason lands in failure_reason and the import panel shows it. The progress poll sends only the first 50 detail entries, because no client reads the list and the panel polls every two seconds; errors is always the exact count.

Rate Limits

Action Limit
Cloud file browsing 30 requests/minute per user
Cloud file import 10 imports/hour per user

OAuth2 Flow

User clicks "Connect" on a cloud drive
    → GET /api/connectors/connect/<slug>/start
    → adapter.start_connect(request) builds authorization URL
    → User redirected to provider (Google, Microsoft)
    → User authorizes
    → Provider redirects to /api/connectors/<slug>/callback
    → adapter.handle_callback(request) exchanges code for tokens
    → Tokens encrypted and stored in Connection model
    → User redirected to /settings/connectors with the outcome in the query

The Connectors settings tab shows that outcome once: "Connected to Google Drive." or "Could not connect to Google Drive. <reason>". It then replaces the URL with a clean /settings/connectors, so a reload or the back button does not replay it.

OAuth2 tokens are automatically refreshed on use via the token_updater callback (built by make_token_updater()).

The callback also records what the person actually consented to: the token response's RFC 6749 scope string is stored on Connection.granted_scopes (normalized by services/oauth.py::normalize_scope), and a refresh whose token carries scope keeps the column current. Scope names are not secrets - they appear in the provider's consent URL - so the column is plaintext, the connections API serializes it as a list, and the Settings -> Connectors row displays each scope as a chip.

Disconnecting reverses the grant, best-effort: the delete endpoint calls the adapter's revoke_token hook before removing the row and records the outcome (revoked, failed, unsupported, or adapter_missing) on the connector_connection_deleted audit entry. A failed revocation never blocks the delete - the user's intent to disconnect always wins - and revocation implementations make a single short-timeout attempt so an unreachable provider cannot stall the request.

Credential Security

All credentials are encrypted at rest with Fernet symmetric encryption, and for Connection.credentials that guarantee is a model contract, not a convention: Connection.save() rejects any value that is not structurally Fernet ciphertext, so a plaintext write raises instead of persisting.

  • Encryption key stored in the APP_FERNET_KEY environment variable (generate via python manage.py generate_fernet_key)
  • Connection.credentials is written only through set_credentials() and read through decrypt_credentials() (raising) or get_credentials() (tolerant, returns None on an undecryptable row - used by the admin display)
  • ConnectorConfig auto-encrypts values marked is_secret=True on save
  • SSOProvider enforces the same save-time invariant on its x509_cert and oidc_client_secret fields
  • Admin UI masks secret values via mask_sensitive_value()

Error Handling

Failure is part of the connector contract - the full rationale is connectors-philosophy.md §14; this is the mechanics.

apps/connectors/errors.py declares the taxonomy: ConnectorError with subclasses ConnectorAuthError, ConnectorRateLimitedError, ConnectorTransientError, ConnectorInvalidRequestError, and ConnectorNotFoundError. The single action entry point (execute_action) wraps the invocation in translated_connector_errors, which maps raw library failures into the taxonomy by exception type and status code, preserving the original as __cause__. Action methods raise taxonomy types directly only for domain cases (empty result, bad parameter); the conformance harness rejects stdlib raises (ValueError, RuntimeError, ...) in actions modules. Unrecognized exceptions escape raw - that is a contract violation, and CloudFileImportService fails the whole batch on one instead of counting it as a per-file error.

Consumers classify by type: the import service records classified failures per-file, the agent tool factory turns them into guidance the model can act on ("ask the user to reconnect"), and the browse/test APIs map them to honest statuses. Auth-classified failures go through apps/connectors/health.py::record_connection_failure, which marks the Connection expired; the next successful use (mark_active, including a passing connection test) clears it. A connection is either active or expired, and mark_expired is the only writer of expired - disconnecting deletes the row rather than parking it in a third state. A check constraint enforces the pair: an active connection carries no error_message and an expired one always carries a reason. The behavioral pins live in apps/connectors/tests/test_connector_errors.py; the static conformance check lives in test_connector_contract.py.

Agent Integration

Connector actions reach the agent through progressive disclosure (philosophy §9): the agent always sees the two bootstrap tools (list_my_connections, load_connector_actions), and loading a connector generates typed {slug}__{action} tools via main_app/services/agent/tools/connector_tool_factory.py, each with a Pydantic args schema introspected from the action signature.

Write Confirmation Runtime

The permission field on @action is enforced at the agent boundary (philosophy §10), in _execute_action_tool:

  • auto (reads) executes immediately.
  • user_confirm never executes from the agent's call. The proposal is persisted as a PendingConnectorAction row (user, connection, action, params, 15-minute expiry), the stream emits an action_confirmation SSE event, and the SPA renders an approve/deny card. The card also re-renders from the saved transcript (an ActionConfirmationEvent in the stored PersistedTurnTrace.events), probing POST /api/connectors/pending-actions/status so an already-decided or expired proposal shows its outcome instead of buttons.
  • admin_approve is refused from chat with a clear message - no admin approval queue exists yet.

Approval (POST /api/connectors/pending-actions/approve) executes the action server-side from the stored row - the client sends only the id, so what the user saw is exactly what runs - then records the JSON result (or the classified error) on the row. Denial records the decision and executes nothing. A row is terminal after one decision; expiry is applied lazily at status/decide time. Check constraints hold the row's companion fields to its status: a row that has left pending carries a decided_at, only an executed row carries a result, a failed row always carries a reason while pending/executing/executed/expired carry none, and only a decided row can be marked outcome_reported. A denied row is free either way, because a user denial explains nothing and a policy denial explains itself.

Decided outcomes flow back to the agent exactly once: chat.cross_turn_notes renders the conversation's undelivered decisions into the next run's dynamic context, so a later turn knows an approved action ran (with its result) and never re-proposes a denied one unprompted. The audit trail records the whole lifecycle (pending_confirmation at proposal, success/error with source: user_confirmation at execution, denied on refusal).

Action Governance

Which connector actions each role may use is an institution decision, stored per action in ConnectorActionPolicy (apps/connectors/models.py): one row per (connector, action_name) with is_enabled plus an enabled_for_roles scope, mirroring feature management's semantics (an enabled action scoped to no roles is usable by nobody).

sync_action_policies (apps/connectors/services/action_policy.py) converges the table with the actions each registered connector declares. Its creation defaults are the safety contract: read actions are created enabled for every active role their excluded_roles denylist does not name (disabled with no roles when the denylist covers every active role), write actions are created disabled with no roles - so a connector upgrade can never silently grant a write capability.

An action can declare excluded_roles on its @action decorator: system-role slugs that must never be in its scope. The scope save refuses newly adding an excluded role, the role drawer disables it with the reason, and "All eligible" skips it. A scope that already holds an excluded role (every deployment synced before the exclusion existed starts this way) is kept - nothing changes behind the admin's back - but the drawer and the row's scope pill flag it, and once removed it cannot be re-added. Runtime evaluation still reads only the policy table, so a deliberately kept violation keeps working until an admin removes it. Existing rows are never re-seeded (an admin's scoping stands), rows for vanished actions are pruned, and permission="admin_approve" actions get no row at all - they stay invisible to the agent, the admin UI, and this table until an admin-approval mechanism exists. The sync runs at boot from run_boot_sequence (after the role registry sync, which it depends on) and via manage.py sync_connector_action_policies. A policy write can name declared actions whose rows were never created, which means the boot sync did not run. That write is refused with plain admin copy carrying no action names, and one ERROR log record names the connector, the connector-wide gap, and the missed sync_connector_action_policies step. The endpoints never self-heal: a skipped boot step must surface, not be papered over.

Evaluation is fail-closed - no row, a disabled row, no role intersection, or a role-less user all mean no - and enforcement covers all three doors:

  1. load_connector_actions filters the advertised schemas to the caller's allowed set (computed once per load, carried on run state in allowed_actions_by_slug), so a denied action is never even visible to the agent.
  2. _execute_action_tool re-checks is_action_allowed live on every call, so a forced call is refused with the institution message.
  3. The pending-action approve endpoint re-checks at decision time, so a held proposal whose action was disabled after it was proposed is DENIED instead of executed.

Admins manage all of it from Settings -> Workspace Connectors: each connector's actions render as governed rows grouped by topic (the action's first non-read/write tag) with read/write badges, role-scope pills, inline switches, a role drawer with reachability guardrails, and bulk "Enable all reads" / "Disable all writes" controls. Saves go through workspace_connectors_api (action_policy_save, action_policies_bulk_save), which refuses enabling with an empty scope, refuses newly scoping unreachable roles, and audits every change. Changes apply on each user's next agent run; there is no live invalidation.

Chat-Attachment File Bridge

Actions that consume a file (upload_course_file) get it through an injected capability, never by importing main_app: ActionContext.file_resolver maps an attachment id to a ResolvedFile (name, MIME type, size, lazy bytes), and BaseConnector.execute_action binds the context onto the actions instance so an action can reach it. main_app implements the resolver over ConversationAttachment - authorized by uploaded_by and pinned to the proposal's conversation - and injects it on both execution paths (the live gate and the approve endpoint). The id an agent holds for a chat file is the SourceFile id (that is what the attachment tools surface), so the resolver matches it first; bytes come from read_original_bytes, which covers both the original FileField and the presigned-upload s3_key store. The confirmation card resolves attachment_id to the file's display name, so the user approves a named file.

The reverse direction - a connector file loaded INTO the chat (Canvas's load_course_file_into_chat, Outlook Mail's load_outlook_attachment_into_chat) - runs through the second injected capability, ActionContext.chat_file_ingestor, implemented by apps/main_app/services/connector_file_ingest.py and injected only on the agent path. The action proves the acting user's upstream access with its own authenticated metadata read and describes the file (identity, declared size, a deferred fetch callable); the hook owns everything on our side: guards against the chat allow-list and the 30 MB cap, versioned dedupe, S3 staging, row creation, enqueue, and a bounded same-turn wait.

The primitive behind the hook is the shared connector-file copy: one processed SourceFile (access_scope="connector") per (connector slug, upstream file id, version key), registered in ConnectorFileCopy with a DB uniqueness constraint. "Loading into a conversation" creates only a ConversationAttachment link row, and the link is the access grant - KnowledgePolicy grants a user exactly the connector files they hold a live link to. An unchanged file is processed once, ever: later loads by any conversation, or by another user whose own connector read of the file succeeds, link the existing copy instantly with no new ingestion run. The version key is the upstream updated_at when the connector supplies one, else a content hash, so a changed upstream file gets a fresh copy: the asking conversation is re-linked to the new version (replace-on-change) while other conversations keep the old one, and a copy is reaped - chunks, S3 object, file row - only when its last link goes. Connector-loaded files display in the conversation's Context panel only, never as message chips.

Adding a New Connector

  1. Create apps/connectors/connectors/my_connector.py
  2. Define an Actions class with @action-decorated methods
  3. Define a BaseConnector subclass with slug, a kind from ConnectorKind, display_name, actions_class
  4. Implement the required auth methods
  5. Create a data migration to seed the Connector catalog entry
  6. Run makemigrations and migrate

The connector is auto-discovered by the registry - no imports to update.

Audit Logging

Every action execution is logged to ConnectorAuditLog with: - Which connection was used - Action name - Success/error status - Request details (params, error info) - Timestamp