Authorization
RBAC roles and scopes, the method policy registry, deny-by-default evaluation, the tenant boundary, and audit logging.
Authorization answers the second question: may this caller do this? Once
authentication has produced a Principal, the authz layer checks its roles and
scopes against the policy each API method declares — deny-by-default.
Scopes: the unit of permission
A scope is an OAuth-style resource:action string — pool:read,
workflow:write, connector:execute. Every scope a request can require lives in a
single immutable typed catalog of over 230 resource:action scopes
(pkg/auth/rbac/scopes.go); an unknown scope fails validation. A reflective
contract test (scopes_contract_test.go) fails CI if any proto's
(authz).scopes annotation references a scope that is not in the catalog — that
is what keeps the API schema and the policy engine from drifting apart. New
scopes are appended to the catalog in declared order; existing ones are not
renamed or removed.
Actions follow a consistent vocabulary — read, write, delete, plus
higher-privilege verbs like execute, control, ops, replay, admin,
promote, decide, and shred on the resources that need them. Wildcards are
supported: pool:* grants every action on pools, and the catalog-wide admin scope
is *:*.
Agent-tool exposure scopes
Agent and MCP tool authorization has its own scope family —
agent_tool_exposure:read, :write, :run, and :admin — which gates what an
agent may see and invoke on the tool surface. Tool authorization is driven by
server-owned descriptors, never caller-supplied labels; see
AI → Agent tool security and
Hardening for the descriptor model.
Roles: bundles of scopes
Callers rarely carry raw scopes; they carry roles, which the authorizer expands to a scope bundle at decision time. The four built-ins:
| Role | Grants | Use for |
|---|---|---|
viewer | Every :read scope in the catalog | Read-only dashboards, auditors |
operator | Every :read and :write scope | Day-to-day operators |
admin | *:* — the tenant-scoped catalog | Tenant administrators; never cross-tenant bypass |
service | Read everywhere + workflow control/ops + queue/event write | Internal plane only |
`operator` is read + write only — by design
The operator bundle is deliberately restricted to read and write actions. It
does not silently acquire delete, execute, control, ops, replay,
promote, or any admin action, and it excludes wildcard scopes (whose action is
*). A workload that legitimately needs, say, connector:execute or
workflow:control must be granted it explicitly through a custom role bundle or
the admin role — never implicitly through operator.
Operators can contribute custom roles (RegisterRole) that bundle any set of
catalog scopes. Built-in role names cannot be redefined, and a role must declare at
least one scope. Custom roles then become assignable in API-key minting and
referenceable in JWT permission claims exactly like the built-ins.
Where roles and scopes come from
- JWTs — roles come from the permissions claim (default name
permissions); scopes come from the space-delimitedscopeclaim. - API keys — roles and scopes are the key's stored role bundle and per-key scope list, unioned at auth time.
Service principals (internal transport plane)
Inter-process callers on the internal transport plane — a workflow coordinator
pod, a webhook ingress proxy — are checked against a service principal
allowlist in addition to the scope catalog. A ServicePrincipal carries a
stable name (reported in audit logs), a role set, and a list of allowed gRPC
method patterns. Admission requires passing both the scope check and the
method allowlist:
- Exact patterns —
/ductor.api.QueueService/Enqueuematches only that method. - Single-segment suffix wildcards —
/ductor.api.WorkflowCoordinatorService/*matches any method one segment deep (/…/Foo) but not deeper paths (/…/Foo/Bar). The wildcard expands to exactly one path segment.
Every pattern must start with /, and every declared role must be a registered
role, or the allowlist fails to load.
Internal-only RPCs are gated separately
The method allowlist controls which internal callers may reach which methods.
Whether an RPC is exposed on the internal plane at all is a separate gate:
the proto internal_only option. An RPC marked internal_only is not
reachable from the public API surface regardless of scope or allowlist — the
two mechanisms compose (transport exposure, then per-principal method
authorization), they do not substitute for each other.
The method policy registry
Every RPC declares its authorization requirement inline in the proto via the
authz option, for example:
rpc CreateApiKey(CreateApiKeyRequest) returns (CreateApiKeyResponse) {
option (authz) = { require_authz: true resource_type: "tenant" action: "write" roles: ["admin"] };
}At startup these annotations are compiled into a method registry. The authz interceptor looks up each incoming method:
- Unregistered method → deny. If a method has no policy entry, the request is
refused (
permission denied). Every endpoint must have an explicit policy; there is no "default allow" for methods someone forgot to annotate. public: true→ allow. A method explicitly flagged public bypasses the Principal and scope checks. This is how/health-style and other intentionally open methods are exposed. See public vs protected routes.- Otherwise → evaluate. The interceptor derives the required action/resource, resolves the target tenant, and calls the Authorizer.
Platform administration is a separate role
platform-admin is a deployment-issued control-plane role, not an alias for tenant
admin. It is the only role that may cross tenant boundaries, and it is required by
the isolated admin MCP surface. Normal tenant role APIs and tenant API-key minting
cannot grant it.
Deny-by-default evaluation
The real authorizer (DefaultAuthorizer) evaluates identity and tenant authority
before ordinary role/scope policy. Tenant admin can satisfy the full catalog only
inside its own tenant:
Two branches lean on other layers: the no-principal check is defense in depth — the interceptor already rejects unauthenticated calls — and the final tenant-boundary check compares the request's target tenant against the principal's, detailed in Tenancy isolation.
Denials never explain themselves
A denied request returns a generic permission denied (Connect
PermissionDenied / gRPC code 7). The specific reason — missing scope, wrong
role, tenant mismatch, policy error — is logged and audited server-side but never
returned to the caller. Policy internals are not an oracle.
Deny-when-unconfigured
Historically, if no authorizer was wired the interceptor failed open (warn and allow). Two mechanisms close that seam:
- Real authorizer by posture. The composition root wires a real deny-by-default authorizer for every authenticated posture (OIDC, API key, SAML, service token). The only path to an allow-all no-op authorizer is the explicit anonymous/development posture; a misconfigured posture fails startup. So in any authenticated deployment there is an authorizer, and the fail-open branch is never reached.
- The flag.
authz.allow_when_unconfiguredstill governs the interceptor's behavior if the authorizer were nil. It defaults totruethis release (byte-identical to prior behavior). Set it tofalseto make a missing authorizer fail closed — a403on the Connect/gRPC path and a503Problem-JSON on the REST path. A future release flips the default.
authz fails OPEN by default — set the flag false for fail-closed
The API-surface survey confirms it: with
authz.allow_when_unconfigured at its default true, an interceptor that
finds a nil authorizer fails open (warn and allow). In a correctly wired
authenticated posture there is always a real authorizer, so this branch is not
reached — but you should not rely on that alone. Operators running a hardened
deployment must explicitly set authz.allow_when_unconfigured=false so a
missing or misconfigured authorizer fails closed rather than admitting the
request.
# Opt into the stricter fail-closed posture ahead of the default flip
export DUCTOR_AUTHZ_ALLOW_WHEN_UNCONFIGURED=falseAudit logging
Every authenticated posture shares an authorization audit logger. Beyond the general structured log, you can route authorization decisions (and API-key lifecycle events) to a dedicated, tamper-evident sink:
| Env var | Config key | Purpose |
|---|---|---|
DUCTOR_AUDIT_SINK | audit.sink | "" (general log only), stderr, stdout, or file |
DUCTOR_AUDIT_SINK_PATH | audit.sink_path | Required when sink is file (opened append-only, 0600) |
DUCTOR_AUDIT_HASH_CHAIN | audit.hash_chain | SHA-256 tamper-evident chaining; default on when a sink is set |
DUCTOR_AUDIT_REDACT_PII | audit.redact_pii | Redact PII; defaults on in production unless set |
DUCTOR_AUDIT_CHAIN_RETENTION | audit.chain_retention | In-memory cap for chain verification; 0 = unbounded |
A misconfigured audit sink fails startup
When a dedicated sink is configured for a real authenticated posture, a
malformed sink config (for example sink=file with no sink_path) is a
startup error — an audit trail that silently isn't recording is not an
acceptable state. Audit-write failures are also exported as a metric so data
loss is alertable.
Where to go next
- Tenancy isolation — the tenant boundary check in detail.
- Entitlements — the capability plane that runs alongside RBAC.
- Concepts → Entitlements — the plan/quota model.
SCIM 2.0 provisioning
Automated user and group lifecycle from your IdP — the /scim/v2 router, the deprovision-revokes-key wire, group-to-role mapping, and bearer-token custody.
Members
The workspace member roster and its lifecycle — invite, assign roles, disable, reenable, and remove — where a member is modeled as a tenant API key plus its RBAC role bundle.