Support
Log In

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:

BoundaryMechanismDecidesA refused caller gets
Curation — same for every callerpublic / internal / privateWhich fields a source exposesA compile error
index.malloyWhich sources can be queried at all404
Authorization — per caller#(authorize)Whether this caller may query this source403
#(access_filter)Which rows this caller sees200, 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
  }
}
AnnotationDecidesIn the exampleA caller it doesn't satisfy
#(authorize)Whether the caller may query the source at allsupport sees403
#(access_filter)Which rows the caller seestheir own customers' tickets200, 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:

GivenDeclare it asFilled 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

ResponseMeaning
404The source is not on the menu.
403The 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.
200The 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) false admits nobody. Use it on a source whose rows or columns no extension re-exposes; put scaffolding off the menu instead.
  • #(authorize) true re-opens a lock inherited through extend. 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) false is 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's accessFilter in 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 through join_many or join_cross are 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 includeSql doesn't include the filter's where:. 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 an accept: 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't rename: 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:

AnnotationTerm shapeExample
#(authorize)'literal' in $GIVEN'support' in $GROUPS
#(access_filter)column in $GIVENtenant 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: in for string[], = for a scalar. Access rules use in, 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 import away.
  • 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
}
DerivationGate behavior
extend or a plain aliasInherits the base's gates; a gate declared on the source replaces the inherited one
Each annotation separatelyDeclaring #(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 depthCarries nothing
Composite sourceThe 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

PlacementValid?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
  • #@ preaggregate on any gated source
  • a filter reached only through a join or derivation base
  • a colocated #@ persist on 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.
  • implicit filters 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.

Next Steps

On this page