Connectors

Policies, Quotas & Audit

Egress (SSRF) policy, tenant provider policy, header-derived quotas, GCRA rate limiting, and the append-only audit hash chain.

Connectors are the point where Ductor makes outbound calls with tenant credentials — the moment an inventory action actually touches the world — so they are governed on four axes: where a call may go (egress policy), which providers a tenant may use (tenant policy), how fast and how much (rate limits and quotas), and what happened (audit). This governance is what lets an action be dispatched by any worker, human or agent, without handing it the keys: the same gates apply whether the caller is a workflow step, an API request, or an agent tool call. This page covers each.

Egress policy (SSRF protection)

Egress policy (domain/connector/egress_policy.go) governs the outbound destination of every connector operation — which schemes, hosts, methods, paths, and redirects a call may reach — per tenant and environment. It is the primary SSRF defense.

The reusable unit is a destination manifest (ConnectorDestinationManifest), a redacted contract that carries no credentials:

  • AllowedSchemes — defaults to https; http requires an explicit policy waiver and can never be credential-bearing.
  • HostPatternsDNS names only. Validation rejects raw IP addresses and a bare *, requires a registrable domain, and allows a wildcard only as *.example.com. This is what blocks a connector from being pointed at 169.254.169.254 or an internal host.
  • HostTemplateFields — dynamic host segments sourced from connection_config (a per-customer subdomain, region code, or resource id), each with a declared class.
  • PathPrefixes, AllowedMethods, RedirectPolicy (deny by default, same_host, or manifest_allowed), and PrivateRouteRequired.

A tenant binds a manifest through a ConnectorEgressPolicyRef with a Mode:

ModeBehavior
enforce(Default) Violations are blocked.
shadowViolations are logged but allowed — use to trial a policy.
disabledNo enforcement.

Egress decisions carry a status (allowed, shadow_allowed, denied, needs_policy, needs_private_route, needs_region, unknown_destination) and are content-addressed for audit. Operations are typed by ConnectorOperationKind (action, sync, trigger, proxy, webhook, function, mapper, unified_operation), so you can scope a policy to just proxy calls, say.

Default-deny on destinations, not just allow-listed hosts

Because host patterns must be registrable DNS names and IPs are rejected outright, a connector cannot be coerced into calling a link-local or private address through a crafted connection_config. Redirects default to deny, so a 302 to an internal host doesn't bypass the manifest either.

Egress policies are managed under /api/v2/connector/policy/egress (list, get, upsert, archive, diff versions), with a POST /api/v2/connector/policy:preview to test a hypothetical destination against the active policy.

Placement: where work runs, not just where it connects

Egress answers where a call may connect; placement answers where the work may run (docs/connector-system/egress-placement-policies.md). Together they form one operator control plane evaluated before credentials, connection selection, and side effects — separate from the low-level pkg/egress dialer, which still performs the final DNS/IP enforcement. Every governed connector call thus produces two redacted decisions:

  • a connector egress decision — may this tenant/environment/provider operation connect to this destination?
  • a connector placement decision — may this operation run from this region, worker label set, or private route?

Destination manifests are materialized from provider, action, sync, webhook, proxy, and function metadata, each with a status: declared (bounded contract), dynamic_needs_policy (dynamic host such as a customer instance URL), unknown (catalog can't safely reduce it — block promotion), or denied. The recommended rollout is shadow → enforce: stand policies up in shadow, run PreviewConnectorPolicy for direct/route/sync/webhook/proxy/function paths, repair unknown_destination, private_route_required, and placement_denied results, then flip production policy to enforce only once shadow denials are empty or explicitly waived. In route mode, a denied candidate connection is skipped; if every candidate is denied, routing returns a policy-denied outcome instead of resolving credentials and failing later.

Egress and permissions are different questions

A permission manifest answers whether a token is authorized for a provider operation; egress/placement policy answers whether a tenant/environment may run that operation from a placement to a destination. Both are required, and their repair paths stay separate: reauthorization fixes permissions, policy authoring fixes destination and placement decisions.

Tenant provider policy

Tenant provider policy (domain/connector/tenant_policy.go) decides which providers a tenant may connect at all. It is enforced at connection creation and at OAuth start, callback, and refresh. The semantics are tighten-only:

  • Deny precedes allow.
  • An empty AllowProviders means "all providers except those denied."
  • An absent policy row means unrestricted.
  • A RequiredReleaseStage can require providers to be at least beta or ga.
  • RequiredCompliance can require providers flagged for PII, GDPR, or SOC 2 scope.

The policy also holds per-provider BYO OAuth overrides: a tenant can supply their own OAuth client id and secret for a provider. The secret is stored as AEAD ciphertext (AAD bound to tenant_id|provider_key) and is never returned — reads mask it to {client_id, has_secret, redirect_url, scopes}. Managed under /api/v2/connector/tenant-policy/{tenant_id} (set, get, delete), with a zero-trust check that the caller's tenant matches the path.

Quotas

Quota (domain/connector/quota.go) tracks how much of a provider's rate budget a tenant has left, so routing can steer away from a connection that's about to be throttled. Quota facts make provider-throttle state durable and routing-safe, complementing — not replacing — the live Redis rate limiter below. Crucially, quota is derived from the provider's own rate-limit response headers, not an internal hard counter. The state is split into three records:

  • ConnectorQuotaPolicy — the tenant/provider bucket, optional connection/action/sync/proxy scope, response header names, spend cost, freshness TTL, and route-safe reserve.

  • ConnectorQuotaObservation — append-only evidence from local gate decisions or provider responses, storing only numeric/time metadata plus safe correlation ids.

  • ConnectorQuotaFact — the current snapshot routing reads.

  • Buckets. A ConnectorQuotaScope combines tenant, provider, connection, action, sync, proxy operation, endpoint class, and surface. Leaving optional fields empty makes a broader bucket (e.g. tenant + provider only). The bucket key drives all quota state.

  • Header policy. Ductor reads standard headers by default — X-RateLimit-Limit / RateLimit-Limit, the matching -Remaining and -Reset, and Retry-After — configurable per policy. Only header names are persisted, never payloads.

  • Readiness. A rolled-up ConnectorQuotaFact grades a bucket ready, near_exhausted, exhausted, backing_off, unknown, or stale. Routing gates on it: ready/near_exhausted proceed; unknown proceeds only if the policy allows unknown quota; backing_off waits until its next-eligible time; stale is treated as not-ready.

  • Route-safe reserve. A policy can keep a headroom of units unspent so routing stops using a connection before it hits a hard 429.

Quota is managed under /api/v2/connector/quota/... — list facts, get a fact by bucket key, preview the impact of a hypothetical spend, upsert or delete a policy, list observations, and clear a backoff.

Rate limiting

Where quota is advisory and header-derived, rate limiting is an active gate (cmd/ductor/fx_connector_ratelimit.go). It is default-off: enabled by connector.action_rate_limit.enabled, and when on it requires the flow Redis (it fails startup otherwise). The limiter is GCRA (a leaky-bucket variant) implemented in Lua over Redis, wrapped in a capacity gate.

Rate-limit scopes (domain/connector/ratelimit_scope.go) mirror quota buckets — tenant, provider, connection, action, sync, proxy, surface, plus run/step correlation — and are threaded on the context so the gate can key on exactly the granularity you configure. A DenialHintStore caches upstream 429/5xx Retry-After hints (keyed tenant|provider|action) and is consulted before the gate for fast-fail. The rate-limit interceptor sits in the connector_interceptors chain, ordered so the audit interceptor observes throttling decisions.

Quota vs. rate limit

Quota answers "is this provider about to throttle us?" from the provider's own headers, and steers routing. Rate limit answers "are we exceeding the budget we set?" and actively rejects with ErrThrottled. They're complementary: quota avoids provider-side 429s, the rate limiter enforces your own fairness policy across tenants.

Execution admission and fairness

Quota steers routing and the rate limiter enforces your own budget, but neither is a fairness gate across tenants competing for the same runtime and provider capacity. That is execution admission (Plan 100, docs/connectors/execution-admission.md) — the shared gate connector work enters before it consumes capacity: actions, async action jobs, proxy calls, sync starts, connector functions, provider webhook lifecycle operations, mapper executions, and external MCP tool actions all pass through it.

Admission is one gate with many ways to say no. The insight is that it does not own any of those reasons — quota, budgets, entitlements, and placement each keep their own authority, and admission composes their verdicts into a single receipt:

capacity free group full quota plane budget plane entitlement plane placement plane connector work execution admission admitted queued / deferred blocked_by_quota blocked_by_budget blocked_by_entitlement blocked_by_placement

Policies are tenant/environment-scoped and fail-closed for required enterprise facts, grouping by tenant, environment, provider, execution kind, and resource class. They control max concurrent and max queued executions per fairness group, provider rate/burst hints, resource-class weights, an interactive reserve, and bounded bulk catch-up. Every decision is one of admitted, queued, deferred, blocked_by_quota, blocked_by_budget, blocked_by_entitlement, blocked_by_readiness, blocked_by_placement, blocked_by_capacity, or rejected, recorded as a redacted receipt. Admission composes the other planes — quota still owns throttle state, budgets own spend, entitlements own capability access, placement owns destination/worker placement — it does not replace them.

Connection selection strategies

In route mode, something has to pick which eligible connection to use. Connection selection (application/connector/routing, docs/connectors/connection-selection-strategies.md) is the strategy plane that chooses one connection from an eligible fallback chain and returns a rich decision — policy lineage, candidate scores, hard eliminations, missing/stale facts, and repair hints — rather than a bare pick. If no policy is active for the request subject, the selector falls back to priority_chain (the existing route-row behavior). Other built-in strategies include least_throttled, healthiest, scope_capability_match, region_affinity, cost_aware, weighted_canary, weighted_failover, and sticky_by_entity. Route-mode output records a connection_selection_decision_id so the ledger and operator workflows can correlate an execution back to the selector decision. Preview, compare, and validation never resolve credentials, call providers, or mutate state.

Audit

Every governed connector event is recorded in an append-only, tamper-evident audit log (infrastructure/connector/auditstore/, table connector_audit_log). Rows are immutable — UPDATE and DELETE are blocked by a database trigger — and each row chains to the previous one by hash.

An audit row records the event type and result, the actor (subject and type — an operator or an end_user), the connection, provider, and action keys, the residency region, and a JSONB metadata blob. Per-tenant sequence numbers (audit_seq) are unique, so a concurrent write surfaces as a retryable race rather than a gap. The chain is verifiable end-to-end: VerifyScan streams rows into a hash-chain verifier that detects any tampering or missing link.

Because the chain is computed over redacted metadata — the audit model, like the lifecycle-event model, forbids credentials, tokens, raw errors, and payload bodies — the log is safe to retain and export without leaking secrets. This tamper-evident record of what an action did is one of the evidence inputs a Receipt draws on when it joins the story of a cleared unit of work.

Where to go next

  • Connections — where tenant policy and egress policy are enforced.
  • Authentication — how BYO OAuth secrets are encrypted.
  • Architecture — the interceptor chain that applies rate limiting and audit.