Security hardening
A catalog decides who can see and change every table, and with credential vending it hands out storage access too. This page lists what to set up before puddle serves anyone you don’t fully trust. The details of each setting are in the configuration reference.
TLS
Puddle serves plain HTTP only. Terminate TLS in front of it: an ingress controller, a load balancer, or a sidecar proxy. Bearer tokens and vended storage credentials travel in requests and responses, so nothing but the proxy should reach puddle’s port over the network.
An inbound X-Request-Id header is kept as the request id; if your
proxy sets one, the same id appears in the proxy’s logs and puddle’s.
Authentication
Configure authn:
in production. Without it (and without authz:) puddle runs as an
open catalog: every request is anonymous and allowed everything, and
puddle logs authentication is disabled at startup. Puddle refuses to
start rather than run open by accident: an authn: block with no
validator, or any authz: setting without authn:, is a startup
error.
- JWT from your identity provider is the usual choice. Issuers,
jwks-uriand discovered JWKS URLs must behttps://(puddle won’t start otherwise), so signing keys can’t be swapped in transit. Restrictaudienceto puddle, andalgorithms:to what your IdP signs with (default[RS256, ES256]);noneis always refused.claims:is an allowlist of the claims authorization sees; with several issuers, policies that grant by subject should checkisstoo, since two issuers can issue the samesub. - Static tokens suit service accounts and small setups. Generate
long random values (
openssl rand -hex 32), give each client its own, and pass them through the environment (see Secrets). Rotating one is a config change and a restart.
GET /api/catalog/v1/whoami shows the principal and claims a token
maps to, exactly as policy sees them, which helps when writing
policy.
Authorization
The default with authentication, allow-authenticated, lets any
valid token do anything. To give principals different rights, choose
a policy: a CEL expression that puddle evaluates in-process, or OPA
(see Authorization
for which fits). Both starters (admin, writer and reader roles from a
groups claim) are in
examples/cel/
and examples/opa/.
- Policy sees only the claims you map (a JWT issuer’s
claims:allowlist, a static token’sclaims:) plussubandiss. With several issuers, a rule that grants by subject must checkisstoo, since two issuers can issue the samesub. - Creating a table by commit is also checked as
table.create, sotable.commiton a namespace’s tables doesn’t let a principal create new ones. - A CEL expression is checked at boot, including the action names it
compares with; at runtime anything but
true, including an evaluation error such as a missing claim, denies. - OPA decisions fail closed: if OPA can’t be reached or answers with
an error, the request fails with a
500and nothing is done. Run OPA next to puddle (a sidecar or a daemonset) and pointauthz.opa.urlat it onlocalhost: puddle calls it over plain HTTP on every request, with the principal and its claims in the input.
Storage credentials
Engines read and write data files directly in the warehouse’s storage, so they need storage credentials. Prefer credential vending to giving engines keys of their own:
- Puddle’s own storage credentials never leave the server. Engines get short-lived STS tokens scoped to the one table’s location.
- A caller the policy allows to commit to the table gets a read-write token; anyone else gets a read-only one. A reader can’t write data files even by going around the catalog.
- Keep
duration-secondsshort (the default is one hour); engines refresh tokens through the credentials endpoint. - Give the catalog’s own storage user only the warehouse bucket or prefix, and the role it assumes only what the policy templates narrow further.
Locations
Puddle authorizes by name, and confines
locations so that a name
only reaches its own files: every table and view has its own
directory in the warehouse, recorded in the metastore, no two of them
overlap, vended credentials cover only the table’s recorded location,
and purge deletes only files under it. This is what makes per-table
vended credentials and per-namespace policies mean anything. Renames
and namespace location changes don’t move files, so they don’t change
what a table’s credentials reach. A table can only move
(set-location) while it has no snapshots, so it never leaves files
behind outside its location.
Never set unrestricted-locations: true on a production warehouse. It
exists for conformance test suites, and turns off the location rules,
including the overlap check: a principal allowed to create a table
anywhere could then place it over another table’s files and get
credentials for them.
Public endpoints
Everything under /api/catalog/ requires authentication, including
/v1/config; an unknown path under it answers 401 before any route
lookup. These paths answer without a token:
| Path | What it exposes |
|---|---|
/healthz | that the process is up |
/readyz | whether the metastore is reachable (no error details; those are logged) |
/ | a small page with the short commit puddle was built from |
/docs/, /assets/ | the Iceberg REST API reference (the public OpenAPI spec) and its UI, the logo |
| any other path | a 400 “No route for request” error |
None of them shows catalog contents. The commit on / tells a caller
which build is running; if that matters, route only /api/catalog/ to
puddle at the proxy and keep the probes on the cluster network.
With telemetry on, the
management listener (its own port, :9090 by default) serves
/metrics and the probes, also without a token. Metric labels never
carry namespaces, tables, principals or locations, but the metrics do
show how busy each warehouse is and which operations fail: expose that
port only to the scraper and the kubelet (see Securing the management
listener).
Secrets
Write every secret in the config as ${VAR} and provide it through
the environment (a Kubernetes Secret, your secret manager’s
injector): static tokens, the Postgres password in postgresql.url,
S3 keys. See Environment variable
expansion.
- Only
${VAR}is expanded. An unset variable stops puddle at startup with the variable’s name and where it’s used, instead of running with an empty secret. - A value is taken literally: a
$inside a secret is safe, and so is a secret containing newlines or YAML. Write$$for a literal$in the config itself. - Prefer the AWS default credential chain (IRSA, instance roles) for
S3 over static keys where you can: leave
access-key-idempty. (Credential vending needs explicit keys to call STS.)
Error responses and logs
Clients never see the details of a server-side failure: a 5xx answer
carries only a generic message and the request id, because storage,
database, STS and OPA errors can name hosts, buckets, roles and users.
The full error is logged at error level as request failed under
the same request_id. When a user reports a failure, ask for the
request id (it is also in the X-Request-Id response header) and
search the logs for it; see Observability.
Logs contain principals, table names and locations, and at debug
level more; treat them as sensitive.
The image
Deploy a release by version or digest and verify its
signature.
The image is distroless (no shell, no package manager) and runs as
uid 65532; it needs no capabilities and works with a read-only root
file system (only its data directory, when it uses SQLite or a
file:// warehouse, needs to be writable). In Kubernetes:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]max-request-body (default 8 MiB) and the HTTP
timeouts bound what a
slow or hostile client can hold; 0s timeouts are for debugging only.