Skip to content
Updated Jun 9, 2026

Granting a Role Access to a Table - a Developer Walkthrough

Status: draft · Owner: Chakshu · For: engineers in their first week · Concern: ABAC (access) Teaches: you've been handed a ticket - "give role R access to table T" - what do you actually edit, and what does the platform validate for you? Worked example, real files. Use the ∑ terse / ¶ plain toggle.


Read this first - the mental model

A role's access to a table is data, not code: a list of rules {column, op, actor_field}. One generic Rego (claim_access.rego) interprets every role's rules - you add a row of data, not policy. The same rule drives the point check ("can A see row X?") and the list filter ("which rows can A see?"), so they can't disagree (no-drift).

Plain-English explanation

You almost never write policy code. Access is expressed as small structured rules - "this column of the row must equal this attribute of the caller." A single, fixed piece of policy logic reads those rules and applies them. So adding access is normally just adding a rule to a data file. And crucially, that one rule is used both to answer "is this specific row allowed?" and to generate the SQL WHERE that lists all allowed rows - they're derived from the same source, so a list can never show a row the per-row check would have denied.

The rule shape you author (service/internal/roles/registry.go):

go
type Rule struct {
    Column     string // a column on the table, e.g. "tenant_id"
    Op         string // "eq" | "in" | "all"
    ActorField string // an attribute on the caller, e.g. "tenant_id" or "tenant_ids"
}

The one interpreter that reads it (policy/claim_access.rego) - you do not edit this to add a role:

rego
allow if {
    some rule in data.claim_access.role_rules[input.actor.type]
    rule_matches(rule)
}
rule_matches(rule) if { rule.op == "eq"; input.claim[rule.column] == input.actor[rule.actor_field] }
rule_matches(rule) if { rule.op == "in"; input.claim[rule.column] == input.actor[rule.actor_field][_] }
rule_matches(rule) if { rule.op == "all" }
opmeaningexampleSQL it becomes
eqcolumn equals one caller attributetenant_id == actor.tenant_idclaim.tenant_id = $1
incolumn is in a caller list attributetenant_id ∈ actor.tenant_idsclaim.tenant_id = ANY($1)
allsee every row (admin)-WHERE true

Multiple rules on one role are OR'd (any match grants the row).


The 90% case - grant a role access to an existing table

Ticket: "A new scheme_manager role should see all claims for the scheme(s) they manage."

Step 1 - Which column is the boundary?

Look at the table. claim has member_id, provider_id, tenant_id, status, amount_pence. A scheme maps to a tenant_id, so that's the column you scope on.

Step 2 - eq or in?

One scheme → eq against actor.tenant_id. Manages several → in against a list actor.tenant_ids. Our manager can have several, so in.

Step 3 - Is the column allowed to be scoped on?

You may only scope on columns someone deliberately exposed. The allowlist (service/internal/translate/translate.go, mirrored in roles):

go
var allowedColumns = map[string]string{
    "id":"uuid", "member_id":"uuid", "provider_id":"uuid",
    "tenant_id":"uuid", "status":"text", "amount_pence":"bigint",
}

tenant_id is there - good. (If it weren't, see the box below.)

Step 4 - Write the rule (the only thing you author)

json
{ "scheme_manager": [ { "column": "tenant_id", "op": "in", "actor_field": "tenant_ids" } ] }
  • POC / dev: POST /roles (or via the harness) - it writes data.claim_access.role_rules.
  • Prod: a PR adding this entry to the role_rules data. No Rego, no service restart.
bash
curl -X POST $HARNESS/roles -H 'content-type: application/json' \
  -d '{"name":"scheme_manager","rules":[{"column":"tenant_id","op":"in","actor_field":"tenant_ids"}]}'

Step 5 - Make sure the caller carries the attribute

The rule references actor.tenant_ids, so the JWT / identity (the PIP) must put tenant_ids on the caller. No attribute ⇒ no match ⇒ deny (fail-closed, not fail-open). This is a Keycloak claim-mapping change, separate from the rule.

Step 6 - There is no step 6

No Rego edit. The interpreter already handles eq/in/all. You're done - now validate.

Need a column the allowlist doesn't have? Add one line to allowedColumns in translate (with its SQL type, for the cast) and roles. That's the deliberate gate - and add an index on it (Step "sargable" below).

Plain-English explanation

If the boundary you need lives in a column that isn't yet allowlisted, you add it in two small maps - one carries the SQL type so the value is cast correctly, the other is the validation allowlist. This is intentional friction: it means no one can accidentally scope access on a column nobody reviewed. While you're there, make sure the column is indexed, or your list queries will scan the whole table.

A brand-new table (not claim)? Bigger job: create the table + indexes on scoping columns; for access that depends on a related table, add an entity to the join config (internal/scenarios, Config) and a small policy, then wire repo access. See ABAC design and the cross-table POC.

Plain-English explanation

Granting access to a table the system doesn't know about yet is more than a rule. You define the table and index the columns you'll scope on. If the access rule depends on a related table - "see an element only if its policy is brokered by you" - that's a join: you register the tables in the join configuration and write a short policy describing the relationship. The cross-table machinery and a worked 3-table example are in the ABAC and POC pages.


What gets validated - the safety net

You don't have to remember to make access safe; these gates run for you. Each rejects a class of mistake:

GateRejectsWhereHow you see it
Rule shapeunknown column, bad op, missing attributeroles.Validateharness VALID / INVALID badge
Translatable / fail-closeda rule that can't become safe SQL ⇒ deny, never "all rows"translateFAIL-CLOSED badge + reason
No-drifta rule where the list and the per-row check disagreeone shared policyNO-DRIFT ✓ badge
Isolationcross-tenant rows, even on an app bugRLS floor + rulesibling-tenant actor returns 0
Sargablea scoping column with no index (slow at scale)EXPLAININDEX SCAN vs SEQ SCAN
Injection-safevalues concatenated into SQLparameterised translateautomatic ($1, not string)

The golden rule the platform enforces: if anything is uncertain, deny. A missing attribute, an untranslatable rule, a typo'd column - all collapse to "no rows," never to "all rows."


Try it - the click loop no typing

  1. Open the harness (create the role first via POST /roles, or pick an existing card).
  2. Click the role → you see its rule, a VALID badge, and the actual Rego that will run.
  3. Pick an actor → List accessible rows → confirm NO-DRIFT ✓, INDEX SCAN, the row count, and the generated SQL.
  4. Point-check: an in-set row → ALLOW, an out-of-set row → DENY.
  5. Any red badge names the gate that failed and why - fix the rule and re-run.

How it flows


Worked example, end to end

Rule authored:

json
{ "scheme_manager": [ { "column": "tenant_id", "op": "in", "actor_field": "tenant_ids" } ] }

Caller (from JWT): { "type": "scheme_manager", "tenant_ids": ["T-1","T-2","T-3"] }

What the platform generates and checks for you:

  • List filter... WHERE claim.tenant_id = $1::uuid OR claim.tenant_id = $2::uuid OR claim.tenant_id = $3::uuid (a BitmapOr of index scans - INDEX SCAN ✓).
  • Point check → the same rule decides one row; list and point agree (NO-DRIFT ✓).
  • Isolation → a scheme_manager for T-1 gets zero rows of T-9.
  • Fail-closed → drop tenant_ids from the caller and the manager sees nothing, not everything.

Onboarding gotchas

  • No index on your scoping columnSEQ SCAN warning. Add the index.
  • Forgot the actor_field attribute in the JWT → everything denies. It's fail-closed by design - check the Keycloak claim mapping (the PIP).
  • You need a range / > / text-match / regextranslate rejects it (fail-closed). The eq/in/all subset is deliberate; richer predicates need a different mechanism - raise it, don't force it.
  • A giant in list (thousands of ids) → query bloat. That's a relationship, not an attribute - model it as a join (see ABAC) or escalate for a relationship engine.
  • Reaching for Rego → almost always unnecessary. If a rule can't express it, that's a signal to discuss the design, not to hand-write policy.

This is the how-to. The why (PEP/PDP/PIP, RLS-vs-OPA split) is in ABAC design; the review/sign-off process a config goes through before prod is Tenant onboarding.

Olly Health Insurance Platform