# Security & auth (/docs/operations/security)



Ductor's security surface spans authentication (who you are), authorization (what
you may do), request hardening (which requests are even accepted), and secret
protection (connector credentials at rest). This page covers the operational
knobs for each.

<Callout title="Looking for the full model?">
  This page is the condensed operator runbook. For the exhaustive reference —
  every credential type, the claim-mapper registry, the RBAC role/scope catalog,
  the entitlement plane, and the cross-tenant boundary — see the
  [Auth & Security](/docs/auth) section:
  [Authentication](/docs/auth/authentication),
  [API keys](/docs/auth/api-keys),
  [Authorization](/docs/auth/authorization),
  [Entitlements](/docs/auth/entitlements),
  [Tenancy isolation](/docs/auth/tenancy-isolation), and
  [Hardening](/docs/auth/hardening).
</Callout>

```mermaid
flowchart LR
    Req["Request"] --> Host["Allowed hosts<br/>+ rate limits"]
    Host --> AuthN["Authentication<br/>OIDC / API key"]
    AuthN --> Tenant["Tenant isolation"]
    Tenant --> AuthZ["Authorization<br/>RBAC roles + scopes"]
    AuthZ --> Ent["Entitlement<br/>enforcement"]
    Ent --> Handler["Handler"]
```

## Authentication [#authentication]

Two mechanisms, controlled by `auth.*` and `api.*`:

### OIDC JWT (primary) [#oidc-jwt-primary]

Validate bearer tokens against your IdP:

```bash
export DUCTOR_AUTH_ENABLED=true
export DUCTOR_API_OIDC_ISSUER="https://your-idp.example.com/"
export DUCTOR_API_OIDC_AUDIENCE="ductor-api"
```

The tenant is derived from the token's claims. Clients send
`Authorization: Bearer <jwt>`.

### DB-backed API keys (service-to-service) [#db-backed-api-keys-service-to-service]

Enable with `auth.api_key_enabled=true`. Keys are created per tenant via
`POST /api/v2/api-keys`, stored bcrypt-hashed, and returned in plaintext once.
See [Issue & use API keys](/docs/guides/api-keys). Clients send `X-API-Key: <secret>`.

<Callout type="warn" title="Never ship anonymous access">
  `auth.allow_anonymous=true` and `auth.enabled=false` are development
  conveniences. In production set `auth.enabled=true` and leave anonymous off.
</Callout>

## Authorization (RBAC) [#authorization-rbac]

API keys and principals carry **roles** (`viewer` / `operator` / `admin` /
`service`, plus module roles) and per-key **scopes** (`resource:action`, e.g.
`pool:write`). Sensitive operations require specific roles and scopes — for
example `CreateApiKey` requires the `admin` role and `tenant:write` scope.

`authz.allow_when_unconfigured` controls the fail posture when no authorizer is
wired: it defaults to `true` (fail-open) but should be set to `false` in
security-sensitive deployments so unconfigured authorization denies rather than
allows.

## Entitlement enforcement [#entitlement-enforcement]

Entitlements gate which capabilities a tenant may use (connectors, strategies,
MCP tools, workflow surfaces). The runtime posture is set by
`entitlement.enforcement` (`DUCTOR_ENTITLEMENT_ENFORCEMENT`):

| Mode               | Behavior                                                                                                  |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| `off`              | Entitlements are not evaluated.                                                                           |
| `report` (default) | Evaluate and emit grace-admit evidence, but do **not** deny traffic — fail-open, but loud and observable. |
| `strict`           | Deny when a required entitlement is missing or the source is stale.                                       |

`report` is the migration-safe default: flip to `strict` only after tenant
entitlement snapshots are provisioned and monitored. In every mode an
**explicit deny** always denies — a snapshot may not carry a conflicting allow
and deny for the same item, and a deny is never overridden by a grace admit.
`entitlement.grace_unprovisioned` lets a strict-mode bootstrap admit a
genuinely un-onboarded tenant (loudly) without fail-opening a
present-but-incomplete snapshot. See [Entitlements](/docs/auth/entitlements).

## Agent & MCP tool security [#agent--mcp-tool-security]

Agent and MCP tools are exposed through **server-owned descriptors**: the tool
surface a caller sees is defined by the server, not supplied by the client, and
defaults to **least privilege**. This keeps an agent (or a compromised prompt)
from inventing tool authority it was never granted. See
[Agent tool security](/docs/ai/agent-tool-security).

## Tenancy isolation [#tenancy-isolation]

Tenant context comes from the authenticated principal (JWT claims), not from
request parameters. An optional `X-Tenant-ID` header may select a tenant but
**must match** the authenticated tenant — a mismatch is rejected with 403. This
is what prevents a caller from acting across tenant boundaries. (`TenantService/
CreateTenant` and health endpoints are the documented exemptions.)

## Request hardening [#request-hardening]

| Control                | Config                                                     | Purpose                                                    |
| ---------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| Allowed hosts          | `api.allowed_hosts`                                        | Reject requests whose `Host` isn't allow-listed            |
| Body size cap          | `api.max_request_body_size` (4 MiB)                        | Bound request bodies                                       |
| Global concurrency     | `api.max_concurrent_requests`                              | Cap in-flight requests                                     |
| Per-tenant concurrency | `api.max_concurrent_requests_per_tenant`                   | Fairness across tenants                                    |
| Rate limits            | `ratelimit.global_rps`, `ratelimit.tenant_rps`             | Throttle; `ratelimit.fail_mode` sets Redis-outage behavior |
| CORS                   | `cors.allowed_origins`                                     | Restrict browser origins                                   |
| Egress                 | `egress.mode` (`shadow`/`enforce`/`disabled`) + allowlists | Guard outbound connector calls                             |

<Callout title="allowed_hosts default is permissive">
  `api.allowed_hosts` defaults to loopback plus `0.0.0.0` for local development.
  Restrict it to your real hostnames in production — the Dokploy stack, for
  example, lists `api.ductor.io` and `docs.ductor.io`.
</Callout>

<Callout type="warn" title="egress.mode defaults to shadow">
  `egress.mode` ships as `shadow` — outbound connector calls that *would* be
  blocked are logged but still allowed, so you can see the impact before
  enforcing. Move it to `enforce` in production; `security_profile=enterprise`
  requires `enforce`. Defaults already block private networks and the cloud
  metadata IP; extend the allow list with `egress.allowed_domains` and
  `egress.allowed_ip_nets`.
</Callout>

## Connector credential encryption [#connector-credential-encryption]

Connector credentials are encrypted at rest with &#x2A;*XChaCha20-Poly1305 (AEAD)**
using a base64-encoded 32-byte master key:

```bash
export DUCTOR_CONNECTOR_ENCRYPTION_KEY="$(openssl rand -base64 32)"
```

* Inject the key from a secret manager — never bake it into an image or commit
  it.
* Rotate by setting a new active key/`connector.encryption_key_id` and keeping
  prior keys in the decrypt-only `connector.rotation_keys` keyring so existing
  ciphertext stays readable.
* Losing the key means losing access to every stored connection; leaking it
  exposes them.

The same master key also protects payload bytes when the AEAD payload codec is
enabled, and BYOK/KEK unwrapping (`key_provider.*`) can source it from an
external KMS or Vault. Rotation is a process-level concern with its own
procedure — see [Key management](/docs/operations/key-management). This is
distinct from per-tenant BYOK custody, covered in
[Key custody](/docs/auth/key-custody).

## Transport security [#transport-security]

* **Public TLS** — `tls.cert_file` / `tls.key_file`, or terminate TLS at your
  ingress (Traefik on Dokploy, an Ingress on Kubernetes).
* **Internal / executor planes** — `server.internal_*` and `server.exec_*`
  support mutual TLS (`*_require_mtls`, cert/key/client-CA files) plus an HMAC
  shared secret for executor RPC.

## Enterprise posture [#enterprise-posture]

Set `security_profile=enterprise` to force fail-closed controls across
subsystems. The Helm chart's `production: true` gate additionally requires this
profile alongside external secrets, loopback-bound metrics, and a non-default
service account — see [Kubernetes (Helm)](/docs/deployment/kubernetes-helm).

## Identity federation (SCIM / SAML) [#identity-federation-scim--saml]

For enterprise SSO and provisioning, enable `identity.saml.*` (SAML SP, ACS URL,
session TTL) and `identity.scim.*` (SCIM 2.0 at `/scim/v2`). Mint a SCIM token
with `ductor scim-token mint`, and manage a tenant's SAML IdP with
`ductor saml-idp set/get/disable`.

## Audit [#audit]

Enable tamper-evident audit logging with `audit.sink` (`stderr`/`stdout`/`file`)
and `audit.hash_chain=true` (SHA-256 chaining, on by default when a sink is set).
Use `audit.redact_pii=true` to redact sensitive fields.
