# Authorization (/docs/auth/authorization)



Authorization answers the second question: &#x2A;*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 [#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-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](/docs/ai/agent-tool-security) and
[Hardening](/docs/auth/hardening) for the descriptor model.

## Roles: bundles of scopes [#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**                          |

<Callout title="`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`.
</Callout>

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

<Callout title="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.
</Callout>

## The method policy registry [#the-method-policy-registry]

Every RPC declares its authorization requirement inline in the proto via the
`authz` option, for example:

```proto
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](/docs/auth#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-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 [#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:

```mermaid
flowchart TD
    A["Request + Principal"] --> B{"Principal present?"}
    B -->|no| D1["deny"]
    B -->|yes| C{"platform-admin role?"}
    C -->|yes| OKP["allow cross-tenant control plane"]
    C -->|no| T{"Tenant boundary ok?"}
    T -->|no| D5["deny"]
    T -->|yes| S{"Holds all required scopes?"}
    S -->|no| D2["deny: missing_scope"]
    S -->|yes| R{"Holds any required role?"}
    R -->|no| D3["deny: missing_role"]
    R -->|yes| M{"Action/resource role ok?"}
    M -->|no| D4["deny: insufficient_role"]
    M -->|yes| OKT["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](/docs/auth/tenancy-isolation).

<Callout title="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.
</Callout>

## Deny-when-unconfigured [#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.

<Callout title="authz fails OPEN by default — set the flag false for fail-closed" type="warn">
  The [API-surface survey](/docs/reference/api-surface) 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.
</Callout>

```bash
# Opt into the stricter fail-closed posture ahead of the default flip
export DUCTOR_AUTHZ_ALLOW_WHEN_UNCONFIGURED=false
```

## Audit logging [#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 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              |

<Callout title="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.
</Callout>

## Where to go next [#where-to-go-next]

* [Tenancy isolation](/docs/auth/tenancy-isolation) — the tenant boundary check in
  detail.
* [Entitlements](/docs/auth/entitlements) — the *capability* plane that runs
  alongside RBAC.
* [Concepts → Entitlements](/docs/concepts/entitlements) — the plan/quota model.
