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 tohttps;httprequires an explicit policy waiver and can never be credential-bearing.HostPatterns— DNS 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 at169.254.169.254or an internal host.HostTemplateFields— dynamic host segments sourced fromconnection_config(a per-customer subdomain, region code, or resource id), each with a declared class.PathPrefixes,AllowedMethods,RedirectPolicy(denyby default,same_host, ormanifest_allowed), andPrivateRouteRequired.
A tenant binds a manifest through a ConnectorEgressPolicyRef with a Mode:
| Mode | Behavior |
|---|---|
enforce | (Default) Violations are blocked. |
shadow | Violations are logged but allowed — use to trial a policy. |
disabled | No 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
AllowProvidersmeans "all providers except those denied." - An absent policy row means unrestricted.
- A
RequiredReleaseStagecan require providers to be at leastbetaorga. RequiredCompliancecan 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
ConnectorQuotaScopecombines 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-Remainingand-Reset, andRetry-After— configurable per policy. Only header names are persisted, never payloads. -
Readiness. A rolled-up
ConnectorQuotaFactgrades a bucketready,near_exhausted,exhausted,backing_off,unknown, orstale. Routing gates on it:ready/near_exhaustedproceed;unknownproceeds only if the policy allows unknown quota;backing_offwaits until its next-eligible time;staleis 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:
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.
Action Pricing
How a connector action gets a commercial price — the two-cost invariant, tenant override precedence, the charge-policy matrix, and why a resolved price is stamped before any provider call.
AI & Agents
Ductor's governed AI vertical — durable chat and workflow agents, provider-neutral inference, least-privilege tools, presets, and an inbound MCP server.