Keep the boundary in the semantic layer
Restrict the record-level explore withsql_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
contextentry tolightdash.project_context.ymlscoped withobjectsto the restricted explore. Any agent reasoning about that explore picks it up, and it survives agents being added or renamed.
lightdash.project_context.yml
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 whosesql_filter keys on the asking user’s verified email:
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, andsql_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:CustomFieldsormanage:CustomSqlTableCalculations.
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’ssql:. 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.