Role-Based Access Control

Time: ~10 minutes. Tangible win: build the full grant chain from scratch and reproduce both a real authorized call and a real denial.

The previous two lessons both used the all-powerful root principal — exactly what you should not do outside a scratch environment. This lesson is what makes handing Polaris credentials to a real engine or a real teammate safe.

Primary source Iceberg REST Catalog Open API spec — the same spec Polaris's management endpoints for principals, roles, and grants extend.

The chain, end to end

Create a Principal, create a Principal Role, assign the role to the principal; create a Catalog Role inside a specific catalog, assign that catalog role to the principal role; then grant a privilege (e.g. CATALOG_MANAGE_CONTENT, or narrower ones like TABLE_READ_DATA / TABLE_WRITE_DATA / NAMESPACE_CREATE) to the catalog role.

Privileges are additive and scoped to what they name — catalog-level (CATALOG_MANAGE_ACCESS, CATALOG_MANAGE_CONTENT, CATALOG_MANAGE_METADATA), table-level (TABLE_CREATE, TABLE_DROP, TABLE_READ_DATA, TABLE_WRITE_DATA), namespace-level (NAMESPACE_CREATE, NAMESPACE_LIST), and view-level privileges are all separate grants — granting one doesn't imply another.

Zero grants means forbidden, not invisible A principal with no grants in a catalog isn't invisible to it — it's explicitly forbidden. Polaris returns 403 Forbidden naming the operation it rejected, not a 404 pretending the catalog doesn't exist. This is server-side authorization checked on every REST call, which is why Lesson 5's Spark session, given a scoped-down credential, would be just as restricted as a raw curl call with the same token.

Exercise

ROOT_TOKEN="principal:root;password:<root-secret>;realm:default-realm;role:ALL"

curl -s -X POST "http://localhost:8181/api/management/v1/principals" -H "Authorization: Bearer $ROOT_TOKEN" \
  -H 'Content-Type: application/json' -d '{"name": "course_engineer", "type": "user"}'

curl -s -X POST "http://localhost:8181/api/management/v1/principal-roles" -H "Authorization: Bearer $ROOT_TOKEN" \
  -H 'Content-Type: application/json' -d '{"principalRole": {"name": "engineer_role"}}'

curl -s -X PUT "http://localhost:8181/api/management/v1/principals/course_engineer/principal-roles" -H "Authorization: Bearer $ROOT_TOKEN" \
  -H 'Content-Type: application/json' -d '{"principalRole": {"name": "engineer_role"}}'

curl -s -X POST "http://localhost:8181/api/management/v1/catalogs/coursecatalog/catalog-roles" -H "Authorization: Bearer $ROOT_TOKEN" \
  -H 'Content-Type: application/json' -d '{"catalogRole": {"name": "catalog_writer"}}'

curl -s -X PUT "http://localhost:8181/api/management/v1/principal-roles/engineer_role/catalog-roles/coursecatalog" -H "Authorization: Bearer $ROOT_TOKEN" \
  -H 'Content-Type: application/json' -d '{"catalogRole": {"name": "catalog_writer"}}'

curl -s -X PUT "http://localhost:8181/api/management/v1/catalogs/coursecatalog/catalog-roles/catalog_writer/grants" -H "Authorization: Bearer $ROOT_TOKEN" \
  -H 'Content-Type: application/json' -d '{"grant": {"type": "catalog", "privilege": "CATALOG_MANAGE_CONTENT"}}'

# Now prove the new principal actually works, scoped to what it was granted:
USER_TOKEN="principal:course_engineer;password:<its-secret>;realm:default-realm;role:ALL"
curl -s -i "http://localhost:8181/api/catalog/v1/coursecatalog/namespaces" -H "Authorization: Bearer $USER_TOKEN"

# ...and prove it's denied against a catalog it was never granted anything on:
curl -s -i "http://localhost:8181/api/catalog/v1/lockedcatalog/namespaces" -H "Authorization: Bearer $USER_TOKEN"

Verified output from a real local run — the authorized call:

HTTP/1.1 200 OK
{"namespaces":[["course_db"]],"next-page-token":null}

...and the denied call against a catalog with no grant:

HTTP/1.1 403 Forbidden
{"error":{"message":"Principal 'course_engineer' with activated PrincipalRoles '[]' and activated grants via '[engineer_role]' is not authorized for op LIST_NAMESPACES","type":"ForbiddenException","code":403}}

Retrieval check

You grant TABLE_READ_DATA to a Principal Role directly. What happens?

Correct. A Principal Role is purely a grouping label; the grant has no effect until a privilege is attached to a Catalog Role and that Catalog Role is assigned to the Principal Role.

Not quite. The API doesn't attach privileges to Principal Roles at all — the grant chain always routes through a Catalog Role.

A principal gets a 403 hitting a catalog. What should you check first?

Correct. Polaris's 403 message names the exact operation and the activated roles it checked — read it before guessing, since it's usually enough to spot the missing grant directly.

Not quite. A 403 means the catalog exists and was reached — it's an authorization failure, not a not-found. Read the message's operation and role details first.

Practice

  1. Reproduce both outcomes — one authorized call, one real 403 — with your own principal and catalog names.
  2. In one sentence, state which single object in the chain you'd delete to revoke course_engineer's access without deleting the principal itself.
  3. Replace CATALOG_MANAGE_CONTENT (broad) with the narrowest privilege that would satisfy the same read-only exercise, and confirm it still works.
Ask the agent: "If I revoke the grant on catalog_writer but leave the principal role and principal assignment intact, does course_engineer lose access immediately or only after its token expires?" Next lesson looks at where Polaris's table model stops assuming Iceberg: Generic Tables.