opa-local
What it demonstrates
Static-token auth + OPA RBAC on top of basic.
Three principals — admin, alice (writer), bob (reader) — gated
by the starter policy from
examples/opa,
inlined. Namespaces are admin-only; writers work with tables and views
inside them; readers only read. puddle queries the policy’s decision
rule, so a denial’s 403 says why.
Run it
cd examples/compose/opa-local
docker compose upThen call the catalog with one of the three tokens. The admin can create a namespace:
curl -X POST -H "Authorization: Bearer admin-secret-replace-me" \
-H "Content-Type: application/json" -d '{"namespace": ["demo"]}' \
http://localhost:8089/api/catalog/v1/default/namespacesThe reader can’t, and the 403 carries the policy’s reason,
bob (groups ["reader"]) may not namespace.create default/other:
curl -X POST -H "Authorization: Bearer reader-secret-replace-me" \
-H "Content-Type: application/json" -d '{"namespace": ["other"]}' \
http://localhost:8089/api/catalog/v1/default/namespacesdocker-compose.yml
docker-compose.yml
# Self-contained docker-compose for running puddle with static-token
# auth + OPA RBAC (the policy in examples/opa). Three principals:
# admin (admin-secret-replace-me) — full access, the only one who
# can create, drop and alter
# namespaces and register tables
# alice (writer-secret-replace-me) — read, plus tables and views
# inside existing namespaces
# bob (reader-secret-replace-me) — read-only
#
# Run:
# docker compose up
# # admin creates a namespace → 200
# curl -X POST -H "Authorization: Bearer admin-secret-replace-me" \
# -H "Content-Type: application/json" -d '{"namespace": ["demo"]}' \
# http://localhost:8089/api/catalog/v1/default/namespaces
# # bob can't → 403, with the policy's reason:
# # bob (groups ["reader"]) may not namespace.create default/other
# curl -X POST -H "Authorization: Bearer reader-secret-replace-me" \
# -H "Content-Type: application/json" -d '{"namespace": ["other"]}' \
# http://localhost:8089/api/catalog/v1/default/namespaces
#
# Tokens are committed deliberately so this works out of the box.
# Replace before exposing to anything that isn't your laptop.
configs:
puddle-config:
content: |
logging:
level: info
format: text
metastore:
type: sqlite
sqlite:
path: /var/lib/puddle/puddle.db
warehouses:
default:
location: file:///var/lib/puddle/warehouse
authn:
static-tokens:
- token: admin-secret-replace-me
principal: admin
claims:
groups: [admin]
- token: writer-secret-replace-me
principal: alice
claims:
groups: [writer]
- token: reader-secret-replace-me
principal: bob
claims:
groups: [reader]
authz:
mode: opa
opa:
# The decision rule: a denial's reason becomes the 403's
# message, so clients see why.
url: http://opa:8181/v1/data/puddle/rbac/decision
# A verbatim copy of examples/opa/rbac.rego (its ./README.md is
# examples/opa/README.md), inlined so this file runs on its own;
# scripts/sync_compose_opa.sh keeps the copy in sync.
opa-policy:
content: |
# puddle default RBAC policy.
#
# This bundle is a starting point — operators are expected to ship
# their own policy in production. See ./README.md for how to swap it
# out and what input shape rules bind to.
#
# Roles:
# admin — every action on every resource, including the admin-only
# ones: creating, dropping and altering namespaces, and
# registering existing tables
# writer — read, plus create/commit/drop/rename tables and views
# inside existing namespaces
# reader — read-only (config.read, *.list, *.load, *.exists)
#
# Two rules to point puddle's `authz.opa.url` at, over the same logic:
# .../puddle/rbac/allow a bare boolean; a denial reaches the
# client as a generic "forbidden"
# .../puddle/rbac/decision {"allow", "reason"}; the reason becomes
# the 403's message, so engines show why
#
# Group→role mapping is read from input.claims.groups[], which the JWT
# issuer's `claims:` mapping (`authn.jwt.issuers[].claims`) or a static
# token's `claims:` block (`authn.static-tokens[].claims`) populates.
package puddle.rbac
import rego.v1
default allow := false
# A multi-resource decision (transaction.commit) is all-or-nothing.
allow if {
count(input.resources) > 0
every r in input.resources {
permitted(input.action, r)
}
}
# permitted(action, resource) is the per-resource hook operators
# typically override. The default rules below are documentation; a
# real deployment will narrow them with warehouse/namespace scoping.
# Admins can do anything.
permitted(_, _) if {
"admin" in user_groups
}
# Reader actions for anyone in reader or writer.
permitted(action, _) if {
action in read_actions
some role in {"reader", "writer"}
role in user_groups
}
# Mutation actions for writers.
permitted(action, _) if {
action in write_actions
"writer" in user_groups
}
# Set of read actions. Add new read-only Action constants here when
# adding endpoints.
read_actions := {
"config.read",
"namespace.list",
"namespace.load",
"namespace.exists",
"table.list",
"table.load",
"table.exists",
"view.list",
"view.load",
"view.exists",
"metrics.report",
}
# Set of mutating actions writers may perform. Actions in neither set
# (namespace.create, namespace.drop, namespace.update-properties,
# table.register) are admin-only.
write_actions := {
"table.create",
"table.commit",
"table.drop",
"table.rename",
"view.create",
"view.replace",
"view.drop",
"view.rename",
"transaction.commit",
}
# user_groups extracts the group list off the caller's claims. The
# claim name is "groups"; the JWT issuer's `claims:` mapping can put
# any IdP claim there (e.g. groups: realm_access.roles).
user_groups contains g if {
some g in input.claims.groups
}
# decision is allow plus, for a denial, a reason puddle passes on in
# the 403 ForbiddenException, e.g. "reader (groups ["reader"]) may not
# table.commit prod/analytics.events".
decision := {"allow": true} if allow
decision := {"allow": false, "reason": reason} if {
not allow
names := [resource_name(r) | some r in input.resources]
reason := sprintf("%s (groups %v) may not %s %s", [
input.principal,
sort(user_groups),
input.action,
concat(", ", names),
])
}
# resource_name renders a resource as warehouse/namespace.name, the
# warehouse alone for catalog-wide actions.
resource_name(r) := concat("/", [r.warehouse, concat(".", array.concat(r.namespace, object_name(r)))]) if {
count(r.namespace) > 0
} else := r.warehouse
object_name(r) := [r.table] if r.table
else := [r.view] if r.view
else := []
services:
puddle:
image: ghcr.io/ragnard/puddle:latest
ports: ["8089:8089"]
volumes:
- puddle-data:/var/lib/puddle
configs:
- source: puddle-config
target: /etc/puddle/puddle.yaml
command: ["-c", "/etc/puddle/puddle.yaml"]
depends_on:
opa:
condition: service_started
restart: unless-stopped
opa:
image: openpolicyagent/opa:latest
command: [run, --server, --addr=:8181, --log-level=info, /policy/rbac.rego]
configs:
- source: opa-policy
target: /policy/rbac.rego
restart: unless-stopped
volumes:
puddle-data: