Auth & Security

Trust, Identity & Access

How Ductor authenticates callers, authorizes requests, isolates tenants, and protects secrets — the complete model.

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)who is calling? A credential (JWT, API key, SAML session, or service token) is turned into a verified Principal.
  2. Authorization (authz)may this caller do this? The Principal's roles and scopes are checked against the method's declared policy, deny-by-default.
  3. Tenancy isolationwhose data is this? Every request is pinned to a tenant, and cross-tenant access is refused at the authorizer boundary.
  4. Entitlementsis 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 is the condensed runbook; the pages here are the reference.

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.

X-API-Key / Authorization authn.Principal invalid / missing denied allowed HTTP / gRPC request authnClaimMapper authzinterceptor 401 Authorizer.Authorize(deny-by-default) 403 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

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

FieldSourceUsed for
SubjectJWT sub, or apikey:<id> for API keysAudit attribution
TenantIDtenant_id claim / key's owning tenantTenancy isolation
OrgIDorg_id claimGrouping above tenant
Rolespermissions claim / key role bundleRBAC role checks
Scopesscope claim (space-delimited) / per-key scopesRBAC scope checks
EnvironmentMode / EnvironmentKeysAPI-key environment scopeEnvironment-scoped writes
ClientIDclient_id or azp claimOAuth client attribution
ExpiresAttoken exp / key expiryExpiry enforcement
Claimsraw claim mapExtension / custom policy

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.

The ClaimMapper and the factory registry

The ClaimMapper is the single authn abstraction:

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.)

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:

PostureTriggerResult
anonymousauth.allow_anonymous=true and environment=development (or API disabled)No-op mapper + allow-all authorizer. Dev only.
oidcapi.oidc_issuer and api.oidc_audience setJWT mapper + real deny-by-default authorizer
api_keyauth.api_key_enabled with a wired DB key storeAPI-key mapper + real authorizer
samlidentity.saml.enabled with a wired resolverSAML mapper + real authorizer
service_tokenauth.service_token_secret setService-token mapper + real authorizer
misconfiguredauth required but no identity source can build a PrincipalStartup fails with a typed error

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.

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.

Keep reading in order

The rest of this section drills into each layer. Start with Authentication to configure a credential type, then Authorization for the policy model.