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:
- Authentication (authn) — who is calling? A credential (JWT, API key, SAML session, or service token) is turned into a verified Principal.
- Authorization (authz) — may this caller do this? The Principal's roles and scopes are checked against the method's declared policy, deny-by-default.
- Tenancy isolation — whose data is this? Every request is pinned to a tenant, and cross-tenant access is refused at the authorizer boundary.
- Entitlements — 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 is the condensed runbook; the pages here are the reference.
Authentication
Every credential type: OIDC/JWT, DB-backed API keys, SAML sessions, service tokens — and how to configure each.
Browser-approved CLI & MCP login
Issue short-lived, tenant- and environment-bound credentials without asking users to paste broad API keys into terminals or model context.
API keys
Mint, list, and revoke DB-backed keys through the v2 API, with real curl.
SAML 2.0 SSO
The receive-only Service Provider: the per-tenant /saml router,
replay-before-mint ACS, and attribute→role derivation.
SCIM provisioning
Automated user/group lifecycle from your IdP — deprovision revokes the linked key, group display names map to roles.
Authorization
RBAC roles and scopes, the method policy registry, and deny-by-default.
Members
Invite operators and services, assign roles, suspend access, and remove workspace members.
Entitlements
The capability plane: what it gates and its report / strict / off postures.
Tenancy isolation
X-Tenant-ID, token–tenant matching, and the cross-tenant boundary check.
Key custody (BYOK)
Per-tenant KEK custody, BYOK modes, and the staged crypto-shred state machine.
Hardening
Allowed-hosts, connector credential encryption, agent-tool security, and a production checklist.
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.
- Credential extraction. The authn middleware/interceptor reads
X-API-Keyfirst, then falls back to theAuthorization: Bearer <token>header (gRPC uses thex-api-keyandauthorizationmetadata equivalents). A missing credential on a protected route is a401. - Claim mapping. The configured ClaimMapper validates the credential and
returns a rich
*authn.Principal. A malformed or expired credential is a401(invalid or expired authentication token). The Principal is attached to the request context. - 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. - 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:
| 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 |
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_mapperset — the transport builds the named factory (or a comma-separated chain) from the claim-mapper registry. Built-in factory names arejwt,apikey,service-token, and the SAML session factory; custom factories can be contributed by operators viafx.Decorate.auth.claim_mapperempty (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:
- Ductor API keys — the
duk_prefix, presented inX-API-KeyorBearer. - OIDC / JWT bearer tokens — validated against your IdP's JWKS.
- 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:
| Posture | Trigger | Result |
|---|---|---|
anonymous | auth.allow_anonymous=true and environment=development (or API disabled) | No-op mapper + allow-all authorizer. 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 |
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/docsand/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.