Skip to content
Authorization

Authorization

After authentication, every catalog request is checked before puddle does anything: may this caller perform this action on these resources? authz.mode chooses who answers. This page covers what a policy sees and how each mode decides; the configuration keys themselves are in the reference.

Choosing a mode

ModeWho decidesUse it when
allow-authenticated (default)nobody: any valid token may do anythingevery client is trusted alike: one team, a development catalog, engines behind a private network
celone CEL expression, compiled at boot and evaluated in-processrules over the caller’s claims and the resource fit in one readable expression: roles from groups, a sandbox warehouse, namespaces owned by a team
opaOpen Policy Agent, called over HTTP on every checkpolicy outgrows one expression, needs data the token doesn’t carry, is shared with other systems, or must change without restarting puddle

Without authn: there is nothing to decide on: puddle runs as an open catalog and allows every request (and refuses to start with an authz: block but no authn:).

CEL needs nothing but puddle: no extra service, no network hop, and no way for policy to be unreachable. A change to the expression is a config change and a restart. OPA brings Rego, its tooling (opa test, decision logs, bundles that roll out policy without a restart) and data beyond the token, at the cost of running it next to every puddle and a call per check; if OPA can’t answer, requests fail. The two starter policies, examples/cel/rbac.cel and examples/opa/rbac.rego, are equivalent (tests hold them to the same cases), so starting with CEL and moving to OPA later is a translation, not a redesign.

The caller: principal and claims

Every authenticator describes the caller the same way: a principal (the subject) and claims, a JSON object. Policies see nothing else of the token.

Callerprincipalclaims
static tokenthe token’s principal:its claims: block, plus sub (the principal) and iss (static)
JWTthe token’s subthe claims the issuer’s claims: allowlist maps, under their configured names, plus sub and iss from the verified token
anonymous (no authn:)""{}
  • An allowlist. A JWT claim reaches policy only if the issuer’s claims: maps it, and under the name it’s mapped to, so a policy reads claims.groups whether the IdP calls it cognito:groups or realm_access.roles. The mapping is in the reference.
  • sub and iss are puddle’s. Both are always present on an authenticated caller, and configuration that names either (a JWT mapping, a static token’s claim) is refused at boot.
  • JSON’s types, for both authenticators. A static token’s claims go through a JSON round trip at boot, so level: 3 in the config and "level": 3 in a JWT are the same: a double.
  • A subject is unique per issuer only. With several JWT issuers, two of them can issue the same sub. A rule that grants by principal should check claims.iss too.

GET /api/catalog/v1/whoami echoes the calling token’s principal and claims exactly as policy sees them; it’s the quickest way to check a mapping. Puddle’s own logs record only sub and iss of a caller, not its other claims.

Actions and resources

A check names one action and one or more resources. The actions are fixed, and their names are stable: they’re what policies match on. They’re defined in internal/authz/authz.go (authz.Actions() lists them all).

ActionChecked forResource
config.readGET /v1/configthe warehouse
namespace.listlisting namespacesthe warehouse, or the parent namespace
namespace.createcreating a namespacethe new namespace
namespace.loadloading a namespacethe namespace
namespace.existsHEAD on a namespacethe namespace
namespace.dropdropping a namespacethe namespace
namespace.update-propertiessetting or removing namespace propertiesthe namespace
table.listlisting tablesthe namespace
table.loadloading a table, and refreshing its vended credentialsthe table
table.existsHEAD on a tablethe table
table.createcreating a table (also staged), and a commit that creates onethe table
table.commitcommitting to a tablethe table
table.dropdropping a table, with or without purgethe table
table.renamerenaming a tablethe source and the destination
table.registerregistering a tablethe table
view.listlisting viewsthe namespace
view.loadloading a viewthe view
view.existsHEAD on a viewthe view
view.createcreating or registering a viewthe view
view.replacereplacing a viewthe view
view.dropdropping a viewthe view
view.renamerenaming a viewthe source and the destination
transaction.commita multi-table commitevery table in it
metrics.reportan engine’s metrics reportthe table

whoami is the one catalog endpoint that is authenticated but not authorized.

Each resource has a kind, warehouse, namespace (a list of levels), table and view. kind follows from which fields are set: table, view, namespace (a namespace with at least one level) or warehouse (no namespace). For a list, the resource is what’s being listed.

Two kinds of check need a word more:

  • Creating by commit. A commit that creates its table (a table.commit, or an entry of a transaction.commit, carrying the assert-create requirement, as engines send for create transactions) is also checked as table.create on that table. The right to commit to a namespace’s tables doesn’t include creating new ones.
  • Probes. On a warehouse that vends credentials, table loads, creates, registrations and credential refreshes also check table.commit on the table, to choose between a read-write and a read-only token (see Credential vending). A probe that’s denied isn’t an error, and puddle logs it at debug level as event="authz.probe", not as a decision. A policy must answer a probe exactly as it would the action.

CEL

puddle.yaml
authz:
  mode: cel
  cel:
    expression: |
      "admin" in claims.groups
      || (action.startsWith("table.") && "writer" in claims.groups)

The expression sees four variables:

VariableTypeWhat
principalstringthe caller’s subject
claimsmap(string, dyn)the caller’s claims, with sub and iss
actionstringone of the actions, e.g. "table.commit"
resourceobjectkind, warehouse, namespace (list(string)), table and view

Every resource field is always present, "" or [] when it doesn’t apply, so rules need no has() for them. Besides CEL’s standard library, the strings extension is available (resource.namespace.join("."), principal.lowerAscii()).

Checked at boot

Puddle refuses to start, with CEL’s message, on an expression that doesn’t compile, whose result isn’t bool (or dyn), that names a resource field that doesn’t exist (resource.tabel), or that compares action with a string that isn’t an action (action == "table.write"): a misspelling fails loudly instead of never matching.

Decisions

  • Once per resource. A rename (source and destination) or a transaction.commit (every table) has several resources. The expression is evaluated for each, and the request is allowed only if every evaluation is true. One evaluation sees one resource, so a rule relating them (“both tables in one namespace”) needs OPA.
  • Only true allows. false denies with the 403 message expression returned false, and a result of another type with expression returned <type>.
  • An error denies. An evaluation error, such as claims.groups when the caller has no groups claim, denies with expression failed to evaluate; the CEL error stays out of the response and is logged at info level as event="authz.cel.error" (with action, resource_kind and err; debug for a probe). It’s never a 500.
  • Bounded. Each evaluation has a fixed cost limit; an expression that exceeds it (nested loops over a large claim, say) is denied the same way.

Optional claims: has()

A claim a caller doesn’t have is an evaluation error, which denies and logs. That’s safe, but for a claim some callers legitimately lack, test for it first:

has(claims.groups) && "admin" in claims.groups

("groups" in claims is the same test.) CEL’s && and || absorb an error when the other side decides: false && <error> is false and true || <error> is true. So a rule that allows on one branch still allows when another branch fails, and the starter expression needs no guards: a caller without groups fails every branch and is denied.

Numbers

Claims carry JSON’s types, and JSON numbers are doubles, for static tokens as for JWTs. Comparisons work across number types (claims.level >= 3 and claims.level in [1, 2, 3] are fine), but arithmetic doesn’t mix them: write claims.level + 1.0, or convert with int(claims.level); claims.level + 1 is an evaluation error.

Examples

The starter, examples/cel/rbac.cel (with a runnable puddle.yaml): admin, writer and reader roles from claims.groups. Namespaces and registration are admin-only, writers create, commit to, drop and rename tables and views, readers read.

"admin" in claims.groups
|| (
  action in [
    "config.read",
    "namespace.list", "namespace.load", "namespace.exists",
    "table.list", "table.load", "table.exists",
    "view.list", "view.load", "view.exists",
    "metrics.report",
  ]
  && claims.groups.exists(g, g in ["reader", "writer"])
)
|| (
  action in [
    "table.create", "table.commit", "table.drop", "table.rename",
    "view.create", "view.replace", "view.drop", "view.rename",
    "transaction.commit",
  ]
  && "writer" in claims.groups
)

Everyone reads; a team may do anything in the top-level namespace named after its team claim, and nothing elsewhere:

action.endsWith(".list") || action.endsWith(".load") || action.endsWith(".exists")
|| action == "config.read"
|| (has(claims.team) && resource.namespace.size() > 0
    && resource.namespace[0] == claims.team)

Anything goes in a sandbox warehouse; elsewhere, only one named account from one issuer may change things:

resource.warehouse == "sandbox"
|| action in ["config.read", "table.load", "table.list", "namespace.list"]
|| (claims.iss == "https://login.example.com/" && principal == "etl-service")

A numeric claim:

has(claims.clearance) && claims.clearance >= 2

To see the claims an expression gets, call whoami with the token.

OPA

puddle.yaml
authz:
  mode: opa
  opa:
    url: http://localhost:8181/v1/data/puddle/rbac/decision

Puddle posts every check to url, OPA’s data API, with this input:

{
  "principal": "alice",
  "claims": {"sub": "alice", "iss": "static", "groups": ["writer"]},
  "action": "table.commit",
  "resources": [
    {"kind": "table", "warehouse": "prod", "namespace": ["analytics"], "table": "events"}
  ]
}

principal, claims, action and each resource are as described above, with all of a check’s resources in one input: a policy can relate them (“no transaction spans two namespaces”), and the starter allows only if it allows every one. A probe’s input also carries "probe": true, so OPA’s decision logs can tell it apart; don’t branch on it. A JSON Schema of the input is internal/authz/opa/input.schema.json, for editors and check-jsonschema when writing fixtures for opa eval -i.

The result is a bare boolean, or an object {"allow": false, "reason": "..."} whose reason becomes the 403’s message, so an engine shows why a request was refused (without one the message is forbidden). The starter’s decision rule returns reasons such as alice (groups ["writer"]) may not namespace.create default/analytics.

Decisions fail closed: if OPA can’t be reached or answers with an error, the request fails with a 500 and nothing is done. Run OPA next to each puddle (a sidecar or a daemonset), on localhost: puddle calls it on every check.

examples/opa/ has the starter policy with its tests and a runnable puddle.yaml; the opa-local, trino-rustfs-opa and trino-rustfs-opa-vending compose examples run it with a real OPA.

Decision logs and metrics

Every mode logs each decision at info level with event="authz.decision": decision (allow or deny), action, principal_sub, principal_issuer, the resources and, for a deny, the reason. That’s an audit log of who did what; route it by event. Probes are event="authz.probe" at debug level, and a CEL evaluation error event="authz.cel.error". The metrics puddle_authz_decisions_total and puddle_authz_duration_seconds (by backend: allow-authenticated, cel, opa) and the authorize span are in Observability.