Access Control
How to do row-level security, column scope, and source-level access control in Malloy — access rules as two annotations on the sources callers query, enforced on every query
Access control decides who may query each source in your model and which rows they see. You write each rule once, as annotations on a source, and it is enforced on every query that goes through the model — from workspace chat, MCP agents, dashboards, data apps, and API calls.
Resource permissions decide who can reach an environment or package. This page covers access inside a package.
Fine-grained access control and audit logging are part of the Enterprise plan.
How Access Control Works
Access is decided at two boundaries:
| Boundary | Mechanism | Decides | A refused caller gets |
|---|---|---|---|
| Curation — same for every caller | public / internal / private | Which fields a source exposes | A compile error |
index.malloy | Which sources can be queried at all | 404 | |
| Authorization — per caller | #(authorize) | Whether this caller may query this source | 403 |
#(access_filter) | Which rows this caller sees | 200, with only their rows |
Curation is covered in Curating Discovery; set it up first, because rules go on the sources the menu exports. A source open to everyone who can reach the package needs no rule.
Because rules live in the data model rather than the warehouse:
- One rule per access decision, not per table. Warehouse controls — row access policies in Snowflake, row-level security and policy tags in BigQuery — attach to physical tables and columns.
- Rules are versioned with the model. They are reviewed in Git, published with the model, and rolled back with it.
- Rules are portable. They aren't written in any warehouse's policy syntax, so they survive a warehouse migration.
Rules apply only to queries that go through the model. A BI tool or SQL client connected directly to the warehouse bypasses them, so warehouse permissions still govern direct access.
The Rule: Two Annotations
This rule implements support sees their own customers' tickets and nobody else's on the support domain from the Modeling Overview:
// support_performance.malloy
import "core.malloy"
given:
#(secure)
GROUPS :: string[]
#(secure)
TENANTS :: string[]
#(authorize) 'support' in $GROUPS
#(access_filter) tenant in $TENANTS
source: support_performance is ticket_core include {
private: *
internal: first_response_hours
public: tenant, segment, priority, status, created_at,
ticket_count, open_ticket_count, avg_first_response_hours
} extend {
view: backlog_by_priority is {
group_by: priority
aggregate: open_ticket_count
}
}| Annotation | Decides | In the example | A caller it doesn't satisfy |
|---|---|---|---|
#(authorize) | Whether the caller may query the source at all | support sees | 403 |
#(access_filter) | Which rows the caller sees | their own customers' tickets | 200, with only their rows |
Each annotation sits on its own line directly above the source: line. A source may carry either, both, or neither. With both, the lock is checked first; an admitted caller then sees only the rows the filter allows. No query can widen or remove the filter, so an admin who should see everything queries a separate source.
Where the Rule Lives
Put rules on the sources index.malloy exports, and nowhere else. Sources off the menu need no rule, because no caller can start a query from them.
A gate is checked on the source a query starts from, and never follows a join. If an open source joins a gated one, the gated data is readable through the open source. So protect the exported sources rather than each sensitive table, and keep ungated sources that join gated ones off the menu. See Which Sources a Gate Covers.
Create one analytical domain per access decision. A model over hundreds of tables typically needs rules on only a dozen or so domains.
Different Columns for Different Audiences
Field visibility can't vary by caller, so a column only some callers may see needs two exported sources — one without it, and one with it behind a lock:
given:
#(secure)
GROUPS :: string[]
// Everyone: orders without the sensitive columns
source: orders is orders_core include {
private: *
public: order_id, status, amount, order_count
}
// The billing team only: adds the contact and card columns
#(authorize) 'billing' in $GROUPS
source: orders_billing is orders_core include {
private: *
public: order_id, status, amount, customer_email, credit_card_number, order_count
}Who Is Asking: Secure Givens
A rule reads the caller's identity from givens — named values a model declares at the top of the file and receives at query time, referenced with $. Rules must read secure givens, which Credible fills server-side from the caller's verified identity, ignoring anything the caller sends:
| Given | Declare it as | Filled with |
|---|---|---|
$GROUPS (built in) | #(secure) then GROUPS :: string[] | The names of the caller's groups in Users & Groups, or the group an API key acts as |
| Custom | #(secure) then NAME :: string[] | Values you assign per user, group, or everyone on the Access Control page |
A custom secure given must be set-valued (string[]) and read with in. A scalar secure given is not enforced.
Only gate on a secure given. Every other given is supplied by the caller, who can send any value and pass the gate. Use ordinary givens for parameterizing a query, never as an access boundary.
Assigning Values to a Custom Given
Values live in a lookup table on the Access Control page, not in the model. Each row grants a user (by email), a group, or everyone a list of values — the data values your rule compares against, such as tenant values. A custom given appears on that page once a published model gates on it.
At query time, Credible resolves the caller's email and groups and merges every applicable row into one set. For example, grant the support group ["acme"] and alice@yourco.com ["globex"]: Alice, a support member, sees both tenants, and her teammates see only acme. A caller with no applicable row gets an empty set and matches nothing. Changes to assignments take effect without republishing.
An API key resolves from its group, so an embedded product can scope each tenant with one group and key per tenant — see Tenant Isolation for Embedded Products.
Filtering on $GROUPS
$GROUPS values are group names, matched exactly, including case — a group named West doesn't match a west value. To filter rows by region, create a group per region (west, east, …), add users to their regions, and write #(access_filter) region in $GROUPS.
Every caller — a person, a dashboard, a data app, an API call, or an agent over MCP — is evaluated against the same rule with their own givens. An agent acting for a user gets exactly that user's access. If you need identity resolved from something other than email, contact us.
What Comes Back
| Response | Meaning |
|---|---|
| 404 | The source is not on the menu. |
| 403 | The caller was refused: the lock didn't admit them, or a gate couldn't be applied. The response doesn't say which. Refusals are recorded like any other query; see Security and Monitoring. |
| 200 | The caller is admitted and gets only their rows. |
A refused caller gets a 403 rather than an empty result, because an empty aggregate is a wrong answer. Under a where: false, sum(salary) returns a row of NULL and count() returns 0, which reads like missing data. A 403 can't be mistaken for that.
Failures deny rather than expose. A gate that can't be applied at query time denies the request, and a malformed gate fails to publish.
Secure One Domain
Pick a domain
Choose a category of question that has an access rule in your policy.
Build the layers
Write the base sources and core, with each table's concepts public and raw columns private.
Add the domain and export it
include only the fields the domain should expose, and export it from index.malloy.
Write the rule
Add #(authorize) and #(access_filter) above the domain.
Publish and test
Query as a caller outside the group, then as one inside it with a narrower grant. See Testing a Gate.
The Lock: #(authorize)
#(authorize) is a rule about the caller: it compares a quoted literal against a given and never reads a column.
given:
#(secure)
GROUPS :: string[]
#(authorize) 'finance' in $GROUPS
source: margins is margins_core extend {
measure: total_margin is sum(margin)
}It can read any secure given, not just $GROUPS. For example, #(authorize) 'acme' in $TENANTS admits only callers whose assigned tenants include acme, so you can grant access to individuals without putting emails in the model.
#(authorize) falseadmits nobody. Use it on a source whose rows or columns no extension re-exposes; put scaffolding off the menu instead.#(authorize) truere-opens a lock inherited throughextend. On a source with nothing to inherit it changes nothing and marks the source open on purpose. It's refused beside another#(authorize)line, and it can't re-open a query-source derivation (->), which always keeps its base's gate.- Refused callers still see the source in their agent's context and get a 403 if they query it. Its column values stay out of search for every caller, as described in Value Search and Materialization.
- Comparison is exact and ignores your warehouse's collation:
'Finance'doesn't match["finance"]. - Refused callers can't compile against the source, so an author outside the group can't compile-check it.
The Row Filter: #(access_filter)
#(access_filter) is a rule about the row: it compares a column of the source against a given. Every admitted caller can query the source and sees only the rows their givens cover.
given:
#(secure)
GROUPS :: string[]
// cost_center is a column of the source
#(access_filter) cost_center in $GROUPS
source: margins_by_cost_center is margins_core extend {
measure: total_margin is sum(margin)
}- No matching rows returns 200 with zero rows. To refuse a caller outright, use
#(authorize).#(access_filter) falseis refused at publish in favor of#(authorize) false. - Use it instead of a
where:over a secure given. The filter is reported as the source'saccessFilterin introspection, and it still applies when a derived source projects the filtered column away. - It can read through a
join_one—#(access_filter) account.tenant_slug in $TENANTS. Paths throughjoin_manyorjoin_crossare refused, because the filter would multiply rows. Parent rows with no match have a null in the joined column, so they're dropped, as in an inner join. - Compiling isn't filtered. A caller who passes the lock can compile against the source, and the SQL returned with
includeSqldoesn't include the filter'swhere:. The filter is applied when the query runs. - Rows are protected, not the schema. Admitted callers still see the source's fields and docs. If a column's existence is sensitive, give it its own locked source.
- Dropping the filtered column denies. If a derivation removes the column (
except:, or anaccept:that omits it), requests are denied rather than served unfiltered. A source that declares the filter fails to publish; one that inherits it publishes with a warning and denies every request. Don'trename:a different column onto a dropped filtered column's name — the filter would bind to the wrong data.
What a Gate Body May Say
A gate body is one or more terms joined by and. The term's shape decides which annotation it belongs on:
| Annotation | Term shape | Example |
|---|---|---|
#(authorize) | 'literal' in $GIVEN | 'support' in $GROUPS |
#(access_filter) | column in $GIVEN | tenant in $TENANTS and region in $REGIONS |
- Repeating an annotation adds terms with
and. Two#(access_filter)lines on one source are the same as one line joining both terms. Repeats combine only with the same annotation, never across the two. - The given's type fixes the operator:
inforstring[],=for a scalar. Access rules usein, since only set-valued secure givens are enforced. - A term on the wrong annotation is refused, and the error names the right one.
- Every referenced given must resolve — declared in the gate's model or one
importaway. - No referenced given may have a default, and don't re-declare a base's givens in a model that imports its gated sources.
Refused at load: or, not, !=, <, >, <=, >=, function calls such as upper(region), bare boolean dimensions, terms with no given (such as region = 'us-west'), and two terms naming the same given or the same column. Because there is no or, a rule that needs a choice between two conditions belongs in a boolean column the filter reads.
Which Sources a Gate Covers
A gate is checked on the entry point — the source the query runs against — and never on a joined source.
#(authorize) 'finance' in $GROUPS
source: margins is margins_core extend {
measure: total_margin is sum(margin)
}
// Inherits the lock: finance only
source: margins_by_region is margins extend {
dimension: region is upper(sales_region)
}
// Replaces the inherited lock: exec only
#(authorize) 'exec' in $GROUPS
source: margins_exec is margins extend {}
// NOT gated: anyone who can query orders reads total_margin through the join
source: orders is orders_core extend {
join_one: margins on product_id = margins.product_id
}| Derivation | Gate behavior |
|---|---|
extend or a plain alias | Inherits the base's gates; a gate declared on the source replaces the inherited one |
| Each annotation separately | Declaring #(authorize) replaces only the inherited lock, and the reverse |
Query source (margins -> { ... }) | Always carries the base's gates; any it declares are added with AND |
| Join, at any depth | Carries nothing |
| Composite source | The one member branch Malloy resolves applies its own gates |
When a query collects gates from several sources, every one must pass.
Where the Annotation May Sit
| Placement | Valid? | Effect |
|---|---|---|
Above a standalone source: | ✅ | Gates that source |
On an item in a multi-definition source: block | ✅ | Gates that item only |
Above the source: keyword of a multi-definition block | ✅ | Gates every item in the block |
On a dimension:, measure:, join, or view: line | ❌ | Refused at load. Put sensitive fields in their own source |
On a top-level query: | ❌ | Refused at load. Gate the source the query reads |
File level, ##(authorize) | ❌ | Refused at load. Gate each source instead |
Spell the tag exactly #(authorize) or #(access_filter). Variants such as #(AUTHORIZE), #authorize, #(access-filter), or extra spaces are refused at load, with the fix named.
Testing a Gate
Gates can only be defined in the model; a gate in caller-submitted Malloy is rejected. To test one, save the model, reload the package, and run a query, supplying givens through the notebook's Parameters panel or a givens map. Locally, Publisher trusts the givens you send, so you are simulating an identity. Once published, Credible fills secure givens from the caller's real identity.
Value Search and Materialization
Value search is off on access-controlled sources. The value index is shared across callers, so values are withheld for everyone on a source that carries:
#(authorize)or#(access_filter)- a
#(secure)given, or$GROUPS - a given whose name any source in your organization declared
#(secure)— that name is reserved org-wide
Admitted callers still see the source in get_context and can query the column with execute_query. To keep value search for a column, put it on an ungated source and gate a sensitive companion separately.
Materialization works with a row filter but not a lock. A colocated #@ persist on a source whose own #(access_filter) reads its own column builds normally, and the filter is re-applied on every request; filtered values are only as fresh as the last rebuild. These are refused at publish:
storage=on any gated source#@ preaggregateon any gated source- a filter reached only through a join or derivation base
- a colocated
#@ persiston a source with#(authorize)
See Performance & Cost.
Parameterization
Ordinary givens are also how a source exposes a per-query parameter, replacing the legacy #(filter) annotation. A presentation filter becomes a filter<string> given whose default f'' matches every row:
given:
manufacturer :: filter<string> is f''
source: recalls is conn.table('recalls') extend {
where: manufacturer_name ~ $manufacturer
}Callers supply values in the givens request parameter. The older filterParams parameter is deprecated.
Keep #(filter) for these cases, which fail silently if migrated:
#(filter, required)carries index partition metadata a given can't express. Migrated, the lookup returns zero rows with no error.implicitfilters are row-level security. Replace them with#(access_filter)over a#(secure)given, not an ordinary given.- Date and number ranges have no neutral default.
Have custom access control requirements? Contact us.