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):
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:
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" }| op | meaning | example | SQL it becomes |
|---|---|---|---|
eq | column equals one caller attribute | tenant_id == actor.tenant_id | claim.tenant_id = $1 |
in | column is in a caller list attribute | tenant_id ∈ actor.tenant_ids | claim.tenant_id = ANY($1) |
all | see 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_managerrole 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):
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)
{ "scheme_manager": [ { "column": "tenant_id", "op": "in", "actor_field": "tenant_ids" } ] }- POC / dev:
POST /roles(or via the harness) - it writesdata.claim_access.role_rules. - Prod: a PR adding this entry to the
role_rulesdata. No Rego, no service restart.
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:
| Gate | Rejects | Where | How you see it |
|---|---|---|---|
| Rule shape | unknown column, bad op, missing attribute | roles.Validate | harness VALID / INVALID badge |
| Translatable / fail-closed | a rule that can't become safe SQL ⇒ deny, never "all rows" | translate | FAIL-CLOSED badge + reason |
| No-drift | a rule where the list and the per-row check disagree | one shared policy | NO-DRIFT ✓ badge |
| Isolation | cross-tenant rows, even on an app bug | RLS floor + rule | sibling-tenant actor returns 0 |
| Sargable | a scoping column with no index (slow at scale) | EXPLAIN | INDEX SCAN vs SEQ SCAN |
| Injection-safe | values concatenated into SQL | parameterised translate | automatic ($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
- Open the harness (create the role first via
POST /roles, or pick an existing card). - Click the role → you see its rule, a VALID badge, and the actual Rego that will run.
- Pick an actor → List accessible rows → confirm NO-DRIFT ✓, INDEX SCAN, the row count, and the generated SQL.
- Point-check: an in-set row → ALLOW, an out-of-set row → DENY.
- 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:
{ "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(aBitmapOrof index scans - INDEX SCAN ✓). - Point check → the same rule decides one row; list and point agree (NO-DRIFT ✓).
- Isolation → a
scheme_managerforT-1gets zero rows ofT-9. - Fail-closed → drop
tenant_idsfrom the caller and the manager sees nothing, not everything.
Onboarding gotchas
- No index on your scoping column →
SEQ SCANwarning. Add the index. - Forgot the
actor_fieldattribute in the JWT → everything denies. It's fail-closed by design - check the Keycloak claim mapping (the PIP). - You need a range /
>/ text-match / regex →translaterejects it (fail-closed). Theeq/in/allsubset is deliberate; richer predicates need a different mechanism - raise it, don't force it. - A giant
inlist (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.
