Skip to content

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 up

Then 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/namespaces

The 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/namespaces

docker-compose.yml

Source on GitHub.

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: