Support
Log In

Access Control

How to do row-level security, column masking, and source-level access control in Malloy — access rules as annotations in the model, enforced on every query

Your model is built and documented. This page is about the next stage: securing your modeled data — controlling who sees which sources, rows, and columns, with annotations in the model itself. Fine-grained access control lives in your Malloy models and is enforced on every query. While resource permissions control access at the environment and package level ("can you see this package?"), fine-grained ACLs work at the source level, in three layers:

  1. Row scope — which rows do they see? Filter with a where: clause over a secure given.
  2. Source access — can this caller query the source at all? Gate it with #(authorize). When the expression reads a row field it scopes rows instead — see Row-level #(authorize).
  3. Column scope — which fields are exposed? Restrict them with include blocks and access modifiers, and gate sensitive columns with a separate #(authorize) source.

Row scope and source access decide access from secure givens. Givens are a Malloy language construct — named values a model declares once and receives at query time, referenced with $. A secure given is one Credible fills server-side from the caller's verified identity — their email — so a caller cannot forge it. Column scope builds on the same #(authorize) gate. If you need identity resolved from something other than email, reach out.

Fine-grained access control and audit logging are part of the Enterprise plan. Access is defined in the model and enforced at the gateway on every query, from every surface.

In the Model, Not the Warehouse

Fine-grained access control is an industry-standard capability — data warehouses offer it too, as row access policies in Snowflake or row-level security and policy tags in BigQuery. But warehouse controls attach to the operational shape of your data: physical tables and columns, expressed in each warehouse's own policy language.

Credible attaches the same fine-grained controls to your data model — the interface to your data — which is simpler, makes more sense to admins and agents, and is far easier to manage:

  • Rules live with meaning, versioned like code. The source that defines what orders means also defines who sees which orders — reviewed in Git, published with the model, and rolled back with it, never drifting in a separate policy catalog.
  • Write once, enforced everywhere, portable. One rule applies identically across workspace chat, MCP agents, dashboards, and data apps, and isn't written in any warehouse's policy syntax — so it survives a warehouse migration.

This is separate from discovery curation (explores / queryableSources in publisher.json), which controls which sources are listed and queryable by name — not who may query them.

Row Scope: Secure Givens

Scope rows with a where: clause over a secure given — a given: Credible populates from the caller's verified identity. Givens are declared at the top of the model (not inside a source) and referenced with $. Mark a custom given #(secure) and declare it set-valued (string[]), then filter with a membership test (in) — secure givens are set-valued by design (a scalar secure given isn't enforced).

given:
  #(secure)
  ALLOWED_TENANTS :: string[]

source: orders is conn.table('orders') extend {
  // Each caller sees only the tenants Credible grants them
  where: tenant_id in $ALLOWED_TENANTS

  measure:
    order_count is count()
}

The given's values aren't in the model — they're a lookup table you manage on the Access Control page. Each row grants a user (by email), a group, or everyone (a default) a list of values — the literal data values your where: compares against (here, tenant_id values).

At query time Credible takes the caller's verified email, resolves their groups, and merges every applicable row into one set: a group grant is a floor shared by all its members, and individual users can be topped up with extra values. A caller with no applicable row resolves to an empty set, which matches nothing — access fails closed.

For the example above: grant the support group ["acme"] and alice@yourco.com ["globex"], and Alice — a member of support — resolves $ALLOWED_TENANTS to ["acme", "globex"] and sees both tenants' rows, while her teammates see only acme's.

A request made with an API key resolves the same way from the key's group. That is how a product embedding Credible scopes each tenant — one group and key per tenant, with the tenant's values granted to the group — with nothing to reimplement in application code; see Tenant Isolation for Embedded Products.

Referencing a custom secure given in a published model is also how Credible learns it exists — the attribute appears on the Access Control page once a model gates on it — so declaring #(secure) ALLOWED_TENANTS is only half the setup; assigning values is the other half.

$GROUPS is built-in — declare GROUPS :: string[] in the model (no #(secure) needed), and Credible fills it with the names of the groups the caller belongs to in your organization (the same groups you manage in Users & Groups), with no Access Control assignment needed.

The values are literally the group names, matched against your data exactly, including case — a group named West won't match a west value. So filtering on $GROUPS means naming groups after your data values: to scope rows by region, create a group per region (west, east, …), add each user to their regions, and filter with a membership test:

given:
  GROUPS :: string[]

source: sales_by_group is conn.table('sales') extend {
  // $GROUPS is the caller's group names: a user in groups 'east' and
  // 'west' sees rows where region = 'east' or region = 'west'
  where: region in $GROUPS

  measure:
    revenue is sum(amount)
}

A where: clause is the default way to scope rows. There is a second way — an #(authorize) annotation whose expression reads a row field — but reach for it only when the rule should surface as access policy. See Row-level #(authorize).

Source Access: #(authorize)

Gate whether a caller can query a source at all with #(authorize), an annotation on its own line directly above the source: line, carrying an unquoted, ordinary Malloy boolean expression over the model's givens. A source with no #(authorize) is unrestricted; a source may declare at most one — publishing refuses a source that declares a second. Spell OR inside the expression itself rather than stacking annotations. Any legal Malloy boolean expression is a legal gate — there's no allowlist of accepted shapes; see Row-level #(authorize) for what that covers. Where the annotation may sit, and which sources it reaches, are covered in Which sources #(authorize) covers below.

given:
  GROUPS :: string[]

// Only members of the 'support' group (the team granted the acme tenant
// above) can see ticket details — refund notes, customer conversations
#(authorize) 'support' in $GROUPS
source: support_tickets is conn.table('support_tickets') extend {
  measure:
    open_ticket_count is count() { where: status = 'open' }
}

The gate can read any given, not just $GROUPS — including a custom secure given whose values you assign per user or group on the Access Control page. That lets you grant source access to specific individuals without hardcoding emails in the model:

given:
  #(secure)
  ALLOWED_TENANTS :: string[]

// Only callers whose assigned tenant list includes 'acme' can query this
#(authorize) 'acme' in $ALLOWED_TENANTS
source: acme_orders is conn.table('orders') extend {
  where: tenant_id = 'acme'

  measure:
    order_count is count()
}

Assign dana@yourco.com a user-scope value of ["acme"] and she passes the gate; change or remove the assignment and her access follows — no republish needed.

For a condition too long to read comfortably on one line, point the gate at an ordinary boolean dimension instead of writing the expression on the annotation line: #(authorize) authorized above the source, over dimension: authorized is org_id in $GROUPS declared inside it. Validation follows the reference through to the dimension it names — a given the dimension reaches is checked exactly as if the gate had written the expression out directly. There's no internal/private requirement on that dimension; it's an ordinary one.

Only gate on a secure given. An #(authorize) gate is only as trustworthy as the given it reads:

  • Secure givens can't be forged — a #(secure) string[] given, or the built-in $GROUPS. Credible fills these server-side from the caller's identity and ignores any value the caller sends. This is your access boundary; gate and filter with in.
  • Every other given is caller-supplied — the caller can send any value and pass the gate. Fine for parameterizing a query, never an access boundary.

Which sources #(authorize) covers

An #(authorize) annotation is checked once, on the entry point — the source the query runs against. A source the query reaches only through a join is not gated on its own; the gate never fires on a joined source.

#(authorize) 'finance' in $GROUPS
source: margins is conn.table('margins') extend {
  measure:
    total_margin is sum(margin)
}

// Derived from margins, so it carries the same gate: finance only
source: margins_by_region is margins extend {
  dimension: region is upper(sales_region)
}

// Declares its own gate, which replaces the inherited one: exec only
#(authorize) 'exec' in $GROUPS
source: margins_exec is margins extend {}

// NOT gated. The join does not carry margins' gate, so anyone who can
// query orders can read total_margin through it
source: orders is conn.table('orders') extend {
  join_one: margins on product_id = margins.product_id
}

Four rules follow:

  • extend and a plain alias inherit, and a source's own gate replaces the inherited one. source: child is margins, with no gate of its own, carries margins's gate unchanged; margins_exec above declares its own and that replaces it entirely. This is how you deliberately tighten or loosen a derived source.
  • A query-source derivation is additive, not a replacement. source: child is margins -> { ... } always carries margins's gate — whether or not child also declares its own #(authorize). The two combine with AND rather than the child's own gate replacing the base's, so a query-source derivation can only add restrictions on top of what it derives from, never drop one by re-declaring its own.
  • Joins carry nothing. Reaching a gated source through join_one: / join_many: does not bring its gate along — at any depth, aliased, cross-file, or as a composite member. So joining sensitive data into an ungated source publishes it: above, anyone who can query orders reads total_margin. Keep gated sources out of the join graph of ungated ones.
  • A composite run target resolves precisely. When the run target is a composite source, Malloy resolves it to exactly one member branch per query, and that branch's own gate — plus whatever it derives from — applies.

Where a query does collect gates from more than one source — down a derivation chain, or from the composite branch Malloy resolved — every one of them must pass (AND). A single source declares at most one #(authorize), so there is no stacking to OR within one source; spell an OR inside the expression itself.

Where the annotation may sit. A gate attaches to the one source declaration it is written on. Written anywhere else it silently protects nothing, which is exactly the fail-open case publishing refuses rather than risk: the load is rejected, naming the position, instead of serving a source the author believes is gated.

PlacementValid?What it does
Above a standalone source:Gates that source.
On an item in a multi-definition source: blockGates that one item; a sibling with no annotation of its own is left ungated.
Above the source: keyword of a multi-definition blockGates every item in the block, not just the first. Worth knowing before you put a narrow gate there.
On a dimension:, measure:, join_one:/join_many:, or view: line#(authorize) only gates from the source: line — it is never enforced from inside the source. Refused at load, naming the position. Split sensitive fields into their own gated source instead (see Column Scope).
On a top-level query:Put the gate on the source the query reads, not on the query statement. Refused at load, naming the position.
At the file level — ##(authorize) (two hashes)A withdrawn feature that once applied model-wide. Declare #(authorize) on each source it was meant to protect instead. Refused at load.

The block form, where a gate stays on its own item and does not reach a sibling:

source:
  #(authorize) 'finance' in $GROUPS
  margins is conn.table('margins'),

  // A sibling in the same block. The gate above does NOT reach it
  volumes is conn.table('volumes')

Write the tag exactly #(authorize). Malloy routes an annotation by its literal prefix: a miscased or malformed spelling — #(AUTHORIZE), #authorize, a space before the (, or a stray space inside the brackets — never reaches the gate at all. That is refused at load, naming the malformed annotation and the exact fix, rather than serving the source unrestricted with no warning.

A gate lives in the model and nowhere else — an #(authorize) in caller-submitted Malloy is rejected, so no caller can introduce, replace, or relax one. To test a gate you are writing, save it to the model file, reload the package, and run a query — supplying the givens yourself through the notebook's Parameters panel or a givens map. Locally you set $GROUPS and any secure given by hand, and Publisher trusts whatever you send, so you are simulating an identity rather than enforcing one; once published, Credible fills those givens from the caller's real identity and ignores any caller-supplied copy.

#(index) value search does not work on an access-controlled source.

#(index) opts a dimension's values into value search, letting an agent search the column's actual contents. That search runs against one shared index, not a per-caller query — so on an access-controlled source the values are withheld from every caller, even one who would pass the gate. A source is access-controlled if it carries any of:

  • an #(authorize) gate
  • a #(secure) given, or the built-in $GROUPS
  • a given whose name any source in your organization marked #(secure) — that name is reserved org-wide and filled server-side, so even an unmarked ROLE :: string[] here is access-scoped

The source itself still appears in get_context — its schema, fields, and access-gated status are visible — and a caller reads the values they are authorized for by querying the column with execute_query. Only the pre-built value search is off.

If value search matters for a column, keep it on a source with no access control and gate a sensitive companion separately — the same split shown under Column Scope.

Materialization is partly available on a gated source. A colocated #@ persist builds and serves: the build copies the source's own rows and never evaluates the gate, and the gate is still applied per request as a filter over that copy — so the caller's identity and the gate both stay live. What it costs is freshness, since the column values the gate reads are frozen until the source rebuilds. storage= and #@ preaggregate are still refused — the first serves its table to every caller carrying no gate, the second rolls up past the column the gate reads — as is a gate reached only through a join. See Performance & Cost.

Row-level #(authorize)

Most #(authorize) expressions compare only givens and literals, deciding access to the whole source — a whole-source #(authorize). Reference a column of the source instead, and the same annotation becomes a row-level #(authorize), narrowing to the rows that column allows.

Both are enforced the same way — as a filter on the source's rows, evaluated once at the entry point. The difference is only how much the filter admits: a whole-source gate reads no field, so it admits either every row or none, uniformly for every caller.

given:
  GROUPS :: string[]

// `cost_center` is a COLUMN of the margins table. Read together with the
// given: "which margins rows may this caller read".
#(authorize) cost_center in $GROUPS
source: margins is conn.table('margins') extend {
  measure: total_margin is sum(margin)
}

Every caller may query margins; each sees only the rows whose cost_center is one of their groups. There is no separate annotation for this — the expression decides: reference only givens and literals and you get the whole-source gate; reference a row field and it narrows to the rows that field allows.

No gate verdict returns a 403. A caller the filter admits nowhere gets 200 with zero rows — the request succeeds and returns nothing. That is true of a whole-source gate as well as a row-level one, so no gate denial is visible as a status code. If you have a dashboard, alert, or client branch that treats 403 as "denied", it will not see a gate denial at all.

A 403 still means something, just not this: it is either a package-level access denial decided before any gate runs, or a case where the gate itself could not be attached at all — the entry point's own shape dropped or renamed the field the gate reads, or a given the gate names went unsupplied.

Any legal Malloy boolean expression is a legal gate. There's no allowlist of accepted comparison shapes — function calls (upper(region) = $REGION), like, is not null, a joined-field reference, and ordinary comparisons combined with and/or/not are all accepted. If you can paste the expression into a where: and see what rows it keeps, you can gate with it. A handful of things are refused anyway, because they make the gate unresolvable or meaningless rather than because the grammar is narrow:

  • At most one #(authorize) per source. Declaring a second fails the load, naming both.
  • Every given the gate references must resolve against the model's own given surface — declared in the gate's own model, or one import hop away. This follows a bare dimension reference through too. An unresolvable reference is refused outright.
  • No given the gate references may carry a declared default. A caller supplying nothing would silently get whatever rows that default admits — declare the given with no default, so a caller must supply one. This applies whether or not the expression reads a row field.
  • An annotation anywhere but directly above a source: line is refused, naming the position — see the placement table above.

Two shapes still load, but with a warning:

  • A gate that references no given at all (1 = 1, or false) evaluates identically for every caller — a fixed predicate, not an access rule keyed on identity. (false is the deliberate exception: the locked-base idiom, a source nobody reads directly that a curated extension opens up.)
  • A negated membership test (not (org_id in $GROUPS)) filters correctly for a non-empty given, but an empty given then matches every row instead of none — the opposite of what in $GROUPS alone would do with nothing granted. Prefer a positive membership test wherever the rule can be stated that way.

Match the operator to the given's declared type. cost_center in $GROUPS (a set-valued given with in) and region = $REGION (a scalar given with =/!=/</<=/>/>=) are both fine, and only in over a set-valued given is a real access boundary — the only givens Credible secures are set-valued, so a scalar comparison filters rows but reads a value the caller could have supplied themselves. Mismatching the two (org_id = $GROUPS, a scalar operator against an array-typed given) is not caught at publish — it loads and grafts cleanly, then fails every request at query execution with a warehouse type-conversion error. Use in for an array-typed given, not =/!=.

A gate may reference a joined field (#(authorize) childtable.name in $GROUPS) — the one place a gate reaches through a join, since the join is emitted as part of the gated source's own build.

Watch the cardinality. The join_one is still a left join, but the gate becomes a filter on the joined column — and a parent with no matching child has a null there, which satisfies no comparison. Those rows are dropped rather than surviving with nulls, so the result matches what an inner join would return. Fail-closed, but it changes row counts where an unfiltered left join wouldn't.

Rows are protected; the schema is not. A gate filters data, so the source, its field names, and its documentation stay visible to every caller — a caller who matches no rows sees an empty result over a readable schema. This holds for a whole-source #(authorize) too, since it is also enforced as a filter: no gate hides a schema. Where the existence of a column is itself sensitive, Column Scope — or splitting it into a separate source — is the only answer.

Row-level gate, or plain where:? Default to the Row Scope: Secure Givens approach — a where: clause that filters on a secure given — since it is the simpler tool. Reach for a row-level gate when the rule should read as access policy: it is reported as the source's authorize in introspection, and it survives derivation, since the filter runs inside the base's own build even where a derived source projects the gated column away.

If a derivation drops the column the gate reads (except:, or a narrowing accept: that doesn't re-list it), the grafted filter can no longer resolve, so the request is denied rather than served unfiltered. A source that declares the gate gets a publish error; one that only inherits it publishes with a warning and denies every request at that entry point, leaving the rest of the package serving. The one residual gap: drop the gated column and then rename: a different column onto that exact same name, and the graft resolves again — but now against the wrong data, since there is once more a field with the gate's name to bind to. That takes both a drop and a same-name rename, and still denies unless the two columns' values happen to collide, so it's narrow — but real. Don't recycle a gated column's name.

Parameterization

Not every given is an access control. The same declaration is also how a source exposes a knob — a value the caller supplies per query — which is the job the legacy #(filter) annotation did.

The mapping is direct: a presentation filter becomes a given of type filter<string> whose default f'' matches every row, so the source behaves exactly as it did until a caller supplies a value.

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 targets the #(filter) path and is deprecated.

Two #(filter) roles must keep the annotation. Neither fails loudly, so don't migrate by pattern:

  • #(filter, required) carries index partition metadata a given cannot express — a given has no required flag and binds to no dimension. Migrating one leaves the index partitioned on a value nothing supplies, and the lookup returns zero rows with no error.
  • implicit filters are row-level security. Their replacement is a #(secure) given resolved server-side (see Row Scope), not an ordinary one — migrating them as ordinary givens turns an access decision into a value the caller supplies.

A date or number range has no neutral literal to default to, so it stays on the annotation too.

Column Scope: Restricting Fields

Control which fields a source exposes with Malloy's access modifiers — an include block before extend that says which fields are part of the source's interface. These are static — they can't read a given, so one source can't show a column to some callers and hide it from others.

Say the orders table has five columns: order_id, status, amount, customer_email, and credit_card_number. Either style hides the sensitive ones:

// Denylist style: keep everything except the sensitive fields
source: orders is conn.table('orders') include {
  except: customer_email, credit_card_number
} extend {
  measure:
    order_count is count()
}

// Allowlist style — safer for sensitive tables: a column added to the
// table later stays hidden until you opt it in
source: orders_safe is conn.table('orders') include {
  order_id, status, amount
} extend {
  measure:
    order_count is count()
}

Both expose exactly order_id, status, and amount — querying credit_card_number against either is a compile error, because the field doesn't exist on the source.

Modifiers offer finer grades than in-or-out: prefix a definition with public, internal, or private, or set levels in the include block. An internal field can't be queried but can still be used in definitions — handy for intermediate calculations. For example, include { private: *; public: order_id, status, amount } keeps every field available for computed dimensions while exposing only three. See Access Modifiers in the Malloy documentation for the full rules.

To expose a column to some callers only, split into two sources and gate the full one with #(authorize):

given:
  GROUPS :: string[]

// Everyone: orders without the sensitive columns
source: orders is conn.table('orders') include {
  except: customer_email, credit_card_number
} extend {
  measure:
    order_count is count()
}

// The billing team only: the same table, all five columns
#(authorize) 'billing' in $GROUPS
source: orders_billing is conn.table('orders') extend {
  measure:
    order_count is count()
}

Callers in the billing group query orders_billing and see every column, including credit_card_number. Everyone else queries orders, where the sensitive columns don't exist.

#(authorize) gates querying, not discovery — the gated source still appears in listings (name, fields, docs) to callers who can't query it. To hide it from listings while keeping it queryable for authorized callers, curate it out of discovery with queryableSources: "all" — see discovery curation.


Have custom access control requirements? Contact us to discuss your use case.

Next Steps

On this page