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
| Mode | Who decides | Use it when |
|---|---|---|
allow-authenticated (default) | nobody: any valid token may do anything | every client is trusted alike: one team, a development catalog, engines behind a private network |
cel | one CEL expression, compiled at boot and evaluated in-process | rules over the caller’s claims and the resource fit in one readable expression: roles from groups, a sandbox warehouse, namespaces owned by a team |
opa | Open Policy Agent, called over HTTP on every check | policy 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.
| Caller | principal | claims |
|---|---|---|
| static token | the token’s principal: | its claims: block, plus sub (the principal) and iss (static) |
| JWT | the token’s sub | the 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 readsclaims.groupswhether the IdP calls itcognito:groupsorrealm_access.roles. The mapping is in the reference. subandissare 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: 3in the config and"level": 3in 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 byprincipalshould checkclaims.isstoo.
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).
| Action | Checked for | Resource |
|---|---|---|
config.read | GET /v1/config | the warehouse |
namespace.list | listing namespaces | the warehouse, or the parent namespace |
namespace.create | creating a namespace | the new namespace |
namespace.load | loading a namespace | the namespace |
namespace.exists | HEAD on a namespace | the namespace |
namespace.drop | dropping a namespace | the namespace |
namespace.update-properties | setting or removing namespace properties | the namespace |
table.list | listing tables | the namespace |
table.load | loading a table, and refreshing its vended credentials | the table |
table.exists | HEAD on a table | the table |
table.create | creating a table (also staged), and a commit that creates one | the table |
table.commit | committing to a table | the table |
table.drop | dropping a table, with or without purge | the table |
table.rename | renaming a table | the source and the destination |
table.register | registering a table | the table |
view.list | listing views | the namespace |
view.load | loading a view | the view |
view.exists | HEAD on a view | the view |
view.create | creating or registering a view | the view |
view.replace | replacing a view | the view |
view.drop | dropping a view | the view |
view.rename | renaming a view | the source and the destination |
transaction.commit | a multi-table commit | every table in it |
metrics.report | an engine’s metrics report | the 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 atransaction.commit, carrying theassert-createrequirement, as engines send for create transactions) is also checked astable.createon 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.commiton 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 asevent="authz.probe", not as a decision. A policy must answer a probe exactly as it would the action.
CEL
authz:
mode: cel
cel:
expression: |
"admin" in claims.groups
|| (action.startsWith("table.") && "writer" in claims.groups)The expression sees four variables:
| Variable | Type | What |
|---|---|---|
principal | string | the caller’s subject |
claims | map(string, dyn) | the caller’s claims, with sub and iss |
action | string | one of the actions, e.g. "table.commit" |
resource | object | kind, 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 istrue. One evaluation sees one resource, so a rule relating them (“both tables in one namespace”) needs OPA. - Only
trueallows.falsedenies with the 403 messageexpression returned false, and a result of another type withexpression returned <type>. - An error denies. An evaluation error, such as
claims.groupswhen the caller has nogroupsclaim, denies withexpression failed to evaluate; the CEL error stays out of the response and is logged at info level asevent="authz.cel.error"(withaction,resource_kindanderr; 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 >= 2To see the claims an expression gets, call whoami with the token.
OPA
authz:
mode: opa
opa:
url: http://localhost:8181/v1/data/puddle/rbac/decisionPuddle 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.