Skip to main content
A common setup: your aggregate explores are open to everyone, and the record-level explore underneath them is restricted by role with user attributes. Anyone can ask “what was pipeline in EMEA last quarter?”, but “which EMEA accounts have open opportunities?” only returns the accounts the person asking is allowed to see. The catch is that a question can sound aggregate and still run on the restricted explore. “How many open opportunities do EMEA reps have?” is answered from the account grain, so its denominator depends on who asked. This guide covers how to keep that setup governed and make the agent clear about what a number includes.

Keep the boundary in the semantic layer

Restrict the record-level explore with sql_filter, required_attributes or any_attributes in the model, not with agent instructions. An agent in its default mode inherits the asking user’s attributes on every query, so one restriction covers Explore, dashboards, and every agent that can see the explore. An instruction like “only show users their own region” is guidance the model can misread, and it does nothing in Explore, so the same user could see different rows in the agent and in Explore. Use agent tags for the other half of the problem: an agent that can’t see the record-level explore can’t be talked into querying it. A “Regional overview” agent tagged onto the aggregate explores and a separate “Account desk” agent for the restricted one is easier to reason about than one agent that has to know when to be careful.

Make the agent say who is included

When a number depends on the asking user’s attributes, the answer should say so. Someone with a broad role sees the full population, someone with a narrow role sees a subset, and neither is told unless you tell the agent to say it. Where you put that instruction depends on how many agents need it:
  • One agent: add it to the agent’s instructions. For example: “Answers from the opportunities explore only cover accounts the person asking can see. Say which population the numbers cover in one clause, such as ‘across the accounts you have access to’.”
  • Every agent that touches the explore: add a context entry to lightdash.project_context.yml scoped with objects to the restricted explore. Any agent reasoning about that explore picks it up, and it survives agents being added or renamed.
lightdash.project_context.yml
Describe the population, not the mechanism. “Across the accounts you have access to” is enough. Attribute names and values are implementation detail that doesn’t belong in an answer.

Treat convenience scopes as their own explores

“My accounts”, “my team” and “my open tickets” aren’t restrictions. A manager may be allowed to see the whole region and still mean their own book when they say “my”. Model the scope explicitly so the agent has something concrete to select instead of guessing the right filter. The simplest shape is a dedicated explore whose sql_filter keys on the asking user’s verified email:
The ai_hint maps the phrase to the explore. Without it the agent has to guess between accounts and my_accounts. If ownership lives in another table, a join with sql_on that references ${lightdash.user.email} does the same job. You can’t put the reference inside a dimension’s own sql:. User attributes are only accepted in sql_filter, required_attributes, any_attributes and sql_on. Keep the convenience explore separate from the restricted one. The user’s access rules still apply on top of it, so “my accounts” never widens what they can see.

Keep raw SQL away from restricted roles

User attributes are enforced by the semantic layer, so they apply to every query built from an explore: Explore, charts and dashboards, and AI agents answering in their default (semantic) mode. Anything that runs raw SQL against the warehouse runs outside the semantic layer, and sql_filter, required_attributes and any_attributes do not apply to it:
  • The SQL Runner
  • SQL mode in an AI agent conversation
  • The Run SQL tool of the Lightdash MCP server
  • Hand-written SQL inside a semantic query: custom SQL dimensions, custom SQL metrics, and SQL table calculations. They’re pasted into the query as written, which is why authoring them needs manage:CustomFields or manage:CustomSqlTableCalculations.
Every surface on this list needs a permission that Developer and Admin roles hold by default: manage:SqlRunner for the first three, manage:CustomFields or manage:CustomSqlTableCalculations for the last. A user without those cannot run raw SQL from any of them, whatever an agent’s instructions say. If someone needs raw SQL and warehouse-enforced permissions at the same time, require personal warehouse credentials on the project so their queries run as their own warehouse user, and put the restrictions in the warehouse. In practice: give restricted roles Viewer, Interactive Viewer or Editor, or a custom role without manage:SqlRunner. The agent then follows your semantic layer restrictions for them, whatever its instructions say. Reserve SQL Runner access for the people who own the model. If any of them also need restricting, require personal warehouse credentials on the project.

Protect small groups by limiting the grain, not the filters

An open aggregate explore with enough filters can single out one record. “Churn for enterprise accounts in Lisbon that signed in March” may be a group of one, and the number is then that account’s data, even though the record-level explore never ran. Lightdash has no minimum group size for results, and no per-row masking of a column, because user attributes can’t appear in a dimension’s sql:. Baking a threshold into a few metrics doesn’t help much either: an agent composes filters freely, and the next slice won’t go through those metrics. Two levers work:
  • Keep identifying records on the restricted explore. The open explores should carry the aggregates people need and nothing that identifies a single record.
  • Keep identifying dimensions off the open agent. Use field-level agent tags so the open agent can’t slice by high-cardinality or sensitive dimensions such as signup date, programme status, or postcode. Fewer ways to slice means fewer groups of one.
If one metric genuinely needs a threshold, compute it in dbt so the suppression is fixed in the warehouse regardless of which filters ran, for example a metric that returns null when its group is below the threshold, and expose only that metric on the open explore.