# Trust, Identity & Access (/docs/auth)



Ductor's security surface has four distinct layers, and it is worth keeping them
separate in your head because they fail in different ways and are configured with
different knobs:

1. **Authentication (authn)** — &#x2A;who is calling?* A credential (JWT, API key,
   SAML session, or service token) is turned into a verified **Principal**.
2. **Authorization (authz)** — &#x2A;may this caller do this?* The Principal's roles
   and scopes are checked against the method's declared policy, deny-by-default.
3. **Tenancy isolation** — &#x2A;whose data is this?* Every request is pinned to a
   tenant, and cross-tenant access is refused at the authorizer boundary.
4. **Entitlements** — &#x2A;is this tenant provisioned for this capability?* A
   separate plane gates workflow publish/schedule, strategy selection, queue
   admission, and connector execution against the tenant's plan.

This section documents all four in depth. If you just want the operational knobs,
[Operations → Security & auth](/docs/operations/security) is the condensed
runbook; the pages here are the reference.

<Cards>
  <Card title="Authentication" href="/docs/auth/authentication">
    Every credential type: OIDC/JWT, DB-backed API keys, SAML sessions, service
    tokens — and how to configure each.
  </Card>

  <Card title="Browser-approved CLI & MCP login" href="/docs/auth/device-authorization">
    Issue short-lived, tenant- and environment-bound credentials without asking
    users to paste broad API keys into terminals or model context.
  </Card>

  <Card title="API keys" href="/docs/auth/api-keys">
    Mint, list, and revoke DB-backed keys through the v2 API, with real curl.
  </Card>

  <Card title="SAML 2.0 SSO" href="/docs/auth/sso-saml">
    The receive-only Service Provider: the per-tenant `/saml` router,
    replay-before-mint ACS, and attribute→role derivation.
  </Card>

  <Card title="SCIM provisioning" href="/docs/auth/scim-provisioning">
    Automated user/group lifecycle from your IdP — deprovision revokes the
    linked key, group display names map to roles.
  </Card>

  <Card title="Authorization" href="/docs/auth/authorization">
    RBAC roles and scopes, the method policy registry, and deny-by-default.
  </Card>

  <Card title="Members" href="/docs/auth/members">
    Invite operators and services, assign roles, suspend access, and remove workspace members.
  </Card>

  <Card title="Entitlements" href="/docs/auth/entitlements">
    The capability plane: what it gates and its report / strict / off postures.
  </Card>

  <Card title="Tenancy isolation" href="/docs/auth/tenancy-isolation">
    `X-Tenant-ID`, token–tenant matching, and the cross-tenant boundary check.
  </Card>

  <Card title="Key custody (BYOK)" href="/docs/auth/key-custody">
    Per-tenant KEK custody, BYOK modes, and the staged crypto-shred state
    machine.
  </Card>

  <Card title="Hardening" href="/docs/auth/hardening">
    Allowed-hosts, connector credential encryption, agent-tool security, and a
    production checklist.
  </Card>
</Cards>

## The request pipeline [#the-request-pipeline]

Every call to the `/api/*` surface travels the same path. Understanding it end to
end tells you exactly where a request is accepted or rejected.

```mermaid
flowchart LR
    A["HTTP / gRPC request"] -->|"X-API-Key / Authorization"| B["authn<br/>ClaimMapper"]
    B -->|"authn.Principal"| C["authz<br/>interceptor"]
    B -->|"invalid / missing"| E1["401"]
    C --> D["Authorizer.Authorize<br/>(deny-by-default)"]
    D -->|"denied"| E2["403"]
    D -->|"allowed"| H["Handler"]
```

1. **Credential extraction.** The authn middleware/interceptor reads `X-API-Key`
   first, then falls back to the `Authorization: Bearer <token>` header (gRPC uses
   the `x-api-key` and `authorization` metadata equivalents). A missing credential
   on a protected route is a `401`.
2. **Claim mapping.** The configured **ClaimMapper** validates the credential and
   returns a rich `*authn.Principal`. A malformed or expired credential is a `401`
   (`invalid or expired authentication token`). The Principal is attached to the
   request context.
3. **Authorization.** The authz interceptor looks the method up in the **policy
   registry**, derives the required action/resource, resolves the target tenant,
   and calls the **Authorizer**. Unregistered methods and policy failures are a
   `403` (`permission denied`) — never a leak of *why*.
4. **Handler.** Only a request that survives authn *and* authz reaches the
   application service, which then runs entitlement and tenant checks of its own.

## The Principal [#the-principal]

Everything downstream reads identity from one object. Whatever credential a caller
presents, it is normalized into the same `authn.Principal`:

| Field                                 | Source                                           | Used for                  |
| ------------------------------------- | ------------------------------------------------ | ------------------------- |
| `Subject`                             | JWT `sub`, or `apikey:<id>` for API keys         | Audit attribution         |
| `TenantID`                            | `tenant_id` claim / key's owning tenant          | Tenancy isolation         |
| `OrgID`                               | `org_id` claim                                   | Grouping above tenant     |
| `Roles`                               | permissions claim / key role bundle              | RBAC role checks          |
| `Scopes`                              | `scope` claim (space-delimited) / per-key scopes | RBAC scope checks         |
| `EnvironmentMode` / `EnvironmentKeys` | API-key environment scope                        | Environment-scoped writes |
| `ClientID`                            | `client_id` or `azp` claim                       | OAuth client attribution  |
| `ExpiresAt`                           | token `exp` / key expiry                         | Expiry enforcement        |
| `Claims`                              | raw claim map                                    | Extension / custom policy |

<Callout title="One identity, everywhere">
  Downstream code never branches on "was this a JWT or an API key?" — it reads
  `Roles`, `Scopes`, and `TenantID` off the Principal. That is what lets the same
  authorization and audit logic serve every credential type uniformly.
</Callout>

## The ClaimMapper and the factory registry [#the-claimmapper-and-the-factory-registry]

The **ClaimMapper** is the single authn abstraction:

```go
type ClaimMapper interface {
    GetClaims(ctx context.Context, token string) (*Principal, error)
}
```

The built-in implementations are the JWT mapper, the DB-backed API-key mapper, the
SAML browser-session mapper, the internal service-token mapper, a **chained**
mapper that tries several in order, and a **no-op** mapper for the anonymous/dev
posture. Which mapper the API server uses is decided at startup from config:

* **`auth.claim_mapper` set** — the transport builds the named factory (or a
  comma-separated **chain**) from the claim-mapper registry. Built-in factory
  names are `jwt`, `apikey`, `service-token`, and the SAML session factory; custom
  factories can be contributed by operators via `fx.Decorate`.
* **`auth.claim_mapper` empty (default)** — legacy composition: **API key first,
  then JWT**. When SAML is enabled it is inserted between the two so non-SAML
  tokens still fall through to OIDC.

In practice a caller authenticates with **one of three credential types**, and
the chained mapper resolves them **in order**:

1. **Ductor API keys** — the `duk_` prefix, presented in `X-API-Key` or `Bearer`.
2. **OIDC / JWT bearer tokens** — validated against your IdP's JWKS.
3. **SAML session bearers** — the HS256 session minted after a SAML assertion.

Each mapper inspects the presented credential and returns `ErrMissingToken` when
it is not its kind, which causes **fall-through** to the next mapper. Any other
error (invalid or expired) is **terminal** and returned immediately — the chain
does not keep trying after a credential has been claimed and rejected. This is
what lets a single endpoint accept an API key, a JWT, or a SAML session
interchangeably. (The internal-plane service-token mapper is composed the same
way for coordinator-to-coordinator traffic; see
[Authentication](/docs/auth/authentication#service-tokens-internal-plane).)

## Startup safety: no accidental open door [#startup-safety-no-accidental-open-door]

Ductor refuses to boot the API into an ambiguous auth state. When `api.enabled` is
true, the composition root **classifies the auth posture** and wires accordingly:

| Posture         | Trigger                                                                         | Result                                                  |
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `anonymous`     | `auth.allow_anonymous=true` **and** `environment=development` (or API disabled) | No-op mapper + allow-all authorizer. &#x2A;*Dev only.** |
| `oidc`          | `api.oidc_issuer` **and** `api.oidc_audience` set                               | JWT mapper + real deny-by-default authorizer            |
| `api_key`       | `auth.api_key_enabled` with a wired DB key store                                | API-key mapper + real authorizer                        |
| `saml`          | `identity.saml.enabled` with a wired resolver                                   | SAML mapper + real authorizer                           |
| `service_token` | `auth.service_token_secret` set                                                 | Service-token mapper + real authorizer                  |
| `misconfigured` | auth required but **no** identity source can build a Principal                  | **Startup fails** with a typed error                    |

<Callout title="Misconfiguration fails loud, never open">
  A deployment that enables authentication but wires no identity source is a
  terminal error — the authorizer factory returns an error and the process does
  not start. The only path to an allow-all no-op authorizer is the *explicit*
  anonymous/development posture. There is no silent fall-through to "allow
  everything". Setting `security_profile=enterprise` additionally forbids the
  anonymous posture entirely.
</Callout>

## Public vs protected routes [#public-vs-protected-routes]

Not everything is behind auth. Two categories are open by design:

* **Infrastructure endpoints** served outside the authenticated API chain:
  `/health` (liveness/readiness), `/metrics` (Prometheus, on the metrics port
  `:9090`), and the documentation surfaces `/docs` and `/openapi.yaml`.
* **Methods explicitly flagged public** in the policy registry. The authz
  interceptor short-circuits to *allow* for a method whose policy carries
  `public: true`; **every other method is deny-by-default** — an unregistered
  method is refused rather than admitted.

Everything under `/api/*` that is not flagged public requires a valid Principal
and a passing authorization check.

<Callout title="Keep reading in order">
  The rest of this section drills into each layer. Start with
  [Authentication](/docs/auth/authentication) to configure a credential type,
  then [Authorization](/docs/auth/authorization) for the policy model.
</Callout>
