Access Control
Fine-grained row, column, and source-level access control in your Malloy models
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:
- Row scope — which rows do they see? Filter with a
where:clause over a secure given. - Source access — can this caller query the source at all? Gate it with
#(authorize). - Column scope — which fields are exposed? Restrict them with
includeblocks 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.
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 semantic 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. The source that defines what
ordersmeans also defines who can see which orders. When the model changes, the rules are right there — not in a separate policy catalog drifting out of sync with physical tables. - One definition, every surface. A rule in the model is enforced identically for workspace chat, MCP agents, dashboards, and data apps — you don't re-implement it per consumer.
- Versioned like code. Access rules ship inside the package: reviewed in Git, published with the model, and rolled back with it.
- Portable. The rules aren't written in any warehouse's policy syntax, so they survive a warehouse migration along with the rest of your model.
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 the platform 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.
Referencing a custom secure given in a published model is also how the platform 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)
}Source Access: #(authorize)
Gate whether a caller can query a source at all with #(authorize), a Malloy boolean expression over the model's givens, placed above the source:. A source with no #(authorize) is unrestricted; stack multiple and they combine as OR (any true grants).
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.
An access gate is only as trustworthy as the given it reads.
- Secure — a
#(secure)set-valued given (string[]), or the built-in$GROUPS. Credible resolves these server-side and strips any caller-supplied copy. Filter and gate within. - Bypassable — a plain or scalar given a caller can set. The caller can send the value and pass the gate. Credible flags a non-blocking advisory when an
#(authorize)gate reads a non-secure given. - A
where:over an ordinary (non-secure) given is fine for parameterization — it just isn't an access boundary.
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.