Skip to content
Security hardening

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-uri and discovered JWKS URLs must be https:// (puddle won’t start otherwise), so signing keys can’t be swapped in transit. Restrict audience to puddle, and algorithms: to what your IdP signs with (default [RS256, ES256]); none is always refused. claims: is an allowlist of the claims authorization sees; with several issuers, policies that grant by subject should check iss too, since two issuers can issue the same sub.
  • 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’s claims:) plus sub and iss. With several issuers, a rule that grants by subject must check iss too, since two issuers can issue the same sub.
  • Creating a table by commit is also checked as table.create, so table.commit on 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 500 and nothing is done. Run OPA next to puddle (a sidecar or a daemonset) and point authz.opa.url at it on localhost: 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-seconds short (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:

PathWhat it exposes
/healthzthat the process is up
/readyzwhether 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 patha 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-id empty. (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.