Row-level access
August 5, 2026 · View on GitHub
What this is: how to restrict which rows a caller sees, using givens. This is one application of givens; for allowing/denying a whole source see authorize.md, and for the base mechanism see givens.md.
Two related but distinct things live here — keep them apart:
- Row-level filtering — a source scopes its own rows by a caller-supplied given. This is a convenience and a performance/UX tool (each caller sees only their slice). It is not, by itself, a security boundary: a caller who omits the given may see everything.
- Row-level access control — the same row scoping, made mandatory and validated with an
#(authorize)gate, behind a trusted tier. Now a caller cannot opt out of their slice, and the scoping value is one the trusted tier asserts from verified identity.
Row-level filtering
Declare a given and reference it in the source's where: so every query against the source is scoped
to the supplied value:
##! experimental.givens
#(description="Tenant to scope all rows to")
given: TENANT :: string
source: orders is duckdb.table('orders.parquet') extend {
where: tenant = $TENANT
measure: order_count is count()
}
Every query against orders now returns only the caller's tenant:
curl -X POST .../models/orders.malloy/query \
-H 'content-type: application/json' \
-d '{"query":"run: orders -> { aggregate: order_count }","givens":{"TENANT":"acme"}}'
On its own this is filtering, not access control: because TENANT has no default, a caller who omits
it isn't scoped to a tenant, and a caller who supplies a different tenant sees that tenant's rows.
To make the scoping a boundary, add a gate.
Row-level access control
Pair the scoping where: with an #(authorize) gate so the source is queryable only
when a valid scoping value is asserted. An unset or unsatisfied given fails the gate with HTTP 403,
so there is no "unscoped" path:
##! experimental.givens
given: TENANT :: string
// Deny unless the caller asserts a tenant on the allow-list.
#(authorize) "$TENANT in ['acme', 'globex', 'initech']"
source: orders is duckdb.table('orders.parquet') extend {
where: tenant = $TENANT
measure: order_count is count()
}
#(authorize)decides whether the caller may queryordersat all (unset/invalidTENANT→ 403).where: tenant = $TENANTdecides which rows they get once allowed.
Used together, callers can only reach orders with a recognized tenant, and only ever see that
tenant's rows.
Trusted-tier requirement. Givens are caller-asserted — anyone who can reach the query API can send
{"TENANT":"acme"}. Row-level access control is a real boundary only when Publisher sits behind a trusted tier that authenticates the end user and setsTENANTfrom its own verified context, with the query/MCP API network-isolated from untrusted callers. See authorize.md § Security model for the full deployment contract. Identity-bound givens (values the caller cannot override) are a planned milestone.
Runnable example
examples/governed-analytics implements exactly this pattern in
secured.malloy: orders_secured is gated with
#(authorize) and scoped with where: $ROLE = 'admin' or tenant = $TENANT. It ships in the default
examples environment, so against the running example the same query returns different rows per caller:
API=http://localhost:4000/api/v0/environments/examples/packages/governed-analytics/models
# Admin → every tenant
curl -s -X POST $API/secured.malloy/query -H 'content-type: application/json' \
-d '{"query":"run: orders_secured -> by_tenant","givens":{"ROLE":"admin"}}' # → 3 tenants
# Tenant caller → only their own rows
curl -s -X POST $API/secured.malloy/query -H 'content-type: application/json' \
-d '{"query":"run: orders_secured -> by_tenant","givens":{"TENANT":"acme"}}' # → 1 tenant
Locking the base source
Neither where: nor #(authorize) is walked through joins — both apply to the source a query
enters through. #(authorize) is carried to an extension that declares no gate of its own, but an
extension declaring its OWN gate replaces it. So two things are yours to get right: which sources a
caller can enter through (anything ungated that joins the base hands the base over), and what each
extension re-exposes. Lock the base with #(authorize) "false", re-expose curated, separately-gated
extensions with access modifiers,
and do not rely on a join to carry the lock. See
authorize.md § The entry point, and only the entry point
and § Recommended pattern: locked base and curated extensions.