Skip to content
trino-rustfs-opa

trino-rustfs-opa

What it demonstrates

trino-rustfs plus authentication and authorization. Trino’s iceberg catalog authenticates to puddle as the writer role using a static bearer token, its admin catalog as admin; the starter policy from examples/opa, inlined, gates each request. Namespaces are admin-only, so create the schema through admin and work in it through iceberg.

Run it

cd examples/compose/trino-rustfs-opa
docker compose up
docker compose exec trino trino
CREATE SCHEMA admin.demo;
CREATE TABLE iceberg.demo.t (id bigint);
INSERT INTO iceberg.demo.t VALUES 1;
-- denied: "Failed to create namespace 'other'"
CREATE SCHEMA iceberg.other;

trino --debug shows the cause, with the policy’s reason: ForbiddenException: Forbidden: alice (groups ["writer"]) may not namespace.create default/other.

docker-compose.yml

Source on GitHub.

docker-compose.yml
# Self-contained docker-compose for running puddle with rustfs S3
# storage, Trino as a query engine, and static-token auth + OPA RBAC
# (the policy in examples/opa). Trino's `iceberg` catalog authenticates
# as the `writer` role, its `admin` catalog as `admin`: namespaces are
# admin-only, so create the schema through `admin` and work in it
# through `iceberg`.
#
# Run:
#   docker compose up
#   docker compose exec trino trino
#     CREATE SCHEMA admin.demo;
#     CREATE TABLE iceberg.demo.t (id bigint);
#     INSERT INTO iceberg.demo.t VALUES 1;
#     CREATE SCHEMA iceberg.other;   -- denied; trino --debug shows the
#     -- cause, ForbiddenException: alice (groups ["writer"]) may not
#     -- namespace.create default/other
# Trino UI:    http://localhost:8080
# Catalog REST: http://localhost:8089/api/catalog
#
# To demo the read-only role, swap the `iceberg` catalog's
# iceberg.rest-catalog.oauth2.token below for reader-secret-replace-me.

configs:
  puddle-config:
    content: |
      logging:
        level: info
        format: text

      metastore:
        type: sqlite
        sqlite:
          path: /var/lib/puddle/puddle.db

      warehouses:
        default:
          location: s3://puddle/warehouse
          s3:
            region: us-east-1
            endpoint: http://rustfs:9000
            path-style-access: true
            access-key-id: puddle-example-key
            secret-access-key: puddle-example-secret

      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

  trino-iceberg:
    content: |
      connector.name=iceberg
      iceberg.catalog.type=rest
      iceberg.rest-catalog.uri=http://puddle:8089/api/catalog
      iceberg.rest-catalog.warehouse=default

      # Static bearer token; matches the writer role in puddle's
      # static-tokens. Swap for reader-secret-replace-me to demo the
      # read-only role.
      iceberg.rest-catalog.security=OAUTH2
      iceberg.rest-catalog.oauth2.token=writer-secret-replace-me

      fs.native-s3.enabled=true
      s3.endpoint=http://rustfs:9000
      s3.region=us-east-1
      s3.path-style-access=true
      s3.aws-access-key=puddle-example-key
      s3.aws-secret-key=puddle-example-secret

  # The same catalog as the admin principal, for what writers may not
  # do: CREATE SCHEMA admin.demo, then work in iceberg.demo.
  trino-iceberg-admin:
    content: |
      connector.name=iceberg
      iceberg.catalog.type=rest
      iceberg.rest-catalog.uri=http://puddle:8089/api/catalog
      iceberg.rest-catalog.warehouse=default
      iceberg.rest-catalog.security=OAUTH2
      iceberg.rest-catalog.oauth2.token=admin-secret-replace-me

      fs.native-s3.enabled=true
      s3.endpoint=http://rustfs:9000
      s3.region=us-east-1
      s3.path-style-access=true
      s3.aws-access-key=puddle-example-key
      s3.aws-secret-key=puddle-example-secret

  # 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
      rustfs-init:
        condition: service_completed_successfully
    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

  rustfs:
    image: rustfs/rustfs:latest
    environment:
      RUSTFS_ADDRESS: 0.0.0.0:9000
      RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
      RUSTFS_CONSOLE_ENABLE: "true"
      RUSTFS_VOLUMES: /data/rustfs0
      RUSTFS_ACCESS_KEY: puddle-example-key
      RUSTFS_SECRET_KEY: puddle-example-secret
    ports: ["9000:9000", "9001:9001"]
    volumes:
      - rustfs-data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/health"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 5s
    restart: unless-stopped

  rustfs-init:
    image: amazon/aws-cli:latest
    environment:
      AWS_ACCESS_KEY_ID: puddle-example-key
      AWS_SECRET_ACCESS_KEY: puddle-example-secret
      AWS_DEFAULT_REGION: us-east-1
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        aws --endpoint-url http://rustfs:9000 s3api create-bucket \
          --bucket puddle 2>&1 | grep -v BucketAlreadyOwnedByYou || true
    depends_on:
      rustfs:
        condition: service_healthy
    restart: "no"

  trino:
    image: trinodb/trino:latest
    ports: ["8080:8080"]
    configs:
      - source: trino-iceberg
        target: /etc/trino/catalog/iceberg.properties
      - source: trino-iceberg-admin
        target: /etc/trino/catalog/admin.properties
    depends_on:
      puddle:
        condition: service_started
    restart: unless-stopped

volumes:
  puddle-data:
  rustfs-data: