Auth & Security

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:

RoleGrantsUse for
viewerEvery :read scope in the catalogRead-only dashboards, auditors
operatorEvery :read and :write scopeDay-to-day operators
admin*:* — the tenant-scoped catalogTenant administrators; never cross-tenant bypass
serviceRead everywhere + workflow control/ops + queue/event writeInternal 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-delimited scope claim.
  • 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/Enqueue matches 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:

no yes yes no no yes no yes no yes no yes Request + Principal Principal present? deny platform-admin role? allow cross-tenant control plane Tenant boundary ok? deny Holds all required scopes? deny: missing_scope Holds any required role? deny: missing_role Action/resource role ok? deny: insufficient_role allow same-tenant request

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_unconfigured still governs the interceptor's behavior if the authorizer were nil. It defaults to true this release (byte-identical to prior behavior). Set it to false to make a missing authorizer fail closed — a 403 on the Connect/gRPC path and a 503 Problem-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=false

Audit 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 varConfig keyPurpose
DUCTOR_AUDIT_SINKaudit.sink"" (general log only), stderr, stdout, or file
DUCTOR_AUDIT_SINK_PATHaudit.sink_pathRequired when sink is file (opened append-only, 0600)
DUCTOR_AUDIT_HASH_CHAINaudit.hash_chainSHA-256 tamper-evident chaining; default on when a sink is set
DUCTOR_AUDIT_REDACT_PIIaudit.redact_piiRedact PII; defaults on in production unless set
DUCTOR_AUDIT_CHAIN_RETENTIONaudit.chain_retentionIn-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