# Row-level permissions > Make each embed viewer see only their own data, either with direct permissions in the embed payload or with attribute-based permissions defined in your datasets ## Introduction Row-level permissions make sure each viewer only sees data that belongs to them. For example, you embed one sales dashboard and show it to all of your customers. With row-level permissions, each viewer only sees their own rows: the German manager sees German sales, and the US manager sees US sales. ## How row-level permissions work Row-level permissions work through **permission rules**. A rule says which rows a viewer can see, based on a field in your dataset. For example: - German manager: only sees rows where `orders.country` is `Germany` - US manager: only sees rows where `orders.country` is `United States` Holistics applies the rule as a filter whenever the viewer opens a dashboard or explores data. You can add a rule in two ways. Both give the viewer the same result: - **Option A: Direct permissions in the embed payload.** Your backend writes the whole rule, including the viewer's value, into the embed payload. Nothing to set up in Holistics. ```javascript title="Embed payload (your backend)" const payload = { objects: { dashboards: ["sales_overview"] }, // highlight-start permissions: { row_based: [ { // the dataset field path: { dataset: "ecommerce", model: "orders", field: "country" }, operator: "is", // the viewer's value values: ["Germany"], }, ], }, // highlight-end }; ``` - **Option B: Attribute-based permissions in datasets.** The rule is written once in your dataset and points at a user attribute instead of a fixed value. Your backend then only passes the viewer's value for that attribute in the embed payload. ```aml title="ecommerce.dataset.aml (your Holistics project)" Dataset ecommerce { // highlight-start permission country_access { field: r(orders.country) // the dataset field operator: 'matches_user_attribute' value: 'country' // the user attribute } // highlight-end } ``` ```javascript title="Embed payload (your backend)" const payload = { objects: { dashboards: ["sales_overview"] }, // highlight-start user_attributes: { country: ["Germany"], // the viewer's value }, // highlight-end }; ``` Start with **Option A**: it's the fastest and easiest way to get going. Move to **Option B** once your row-level permission setup grows, with many embeds or more complex rules. You can also [use both at once](#how-the-two-options-combine). ## Option A: Direct permissions in the embed payload Add rules to `permissions.row_based` in your embed payload: ```javascript const payload = { objects: { dashboards: ["sales_overview"], }, // highlight-start permissions: { row_based: [ { path: { dataset: "ecommerce", model: "orders", field: "country" }, operator: "is", values: ["Germany"], }, ], }, // highlight-end exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60, }; ``` **What this does:** every query on `sales_overview` that uses the `ecommerce` dataset gets the condition `orders.country = 'Germany'`. The viewer sees German sales only, and every chart, filter, and drill-down respects that. Each rule has three parts: - **`path`**: the field to restrict, written as its **dataset**, **model**, and **field** unique names. - **`operator`**: how to compare, such as `is`, `is_not`, or `matches`. For relative date operators like `last` and `next`, also set `modifier` to the time unit (`day`, `month`, `year`). - **`values`**: the values the viewer is allowed to see. An empty array means no restriction on that field. :::caution Use the model unique name, not the filename `path.model` must match the name declared after `Model` in AML. If `sales.model.aml` declares `Model sales_orders { ... }`, use `sales_orders`, not `sales`. The model must also belong to the dataset in `path.dataset`. ::: ### Example: different rules per viewer In a real app, your backend picks the values based on who is logged in. Here a general manager sees everything, a country manager sees one country, and a store manager sees one city: ```javascript let countries = []; // empty = no restriction let cities = []; if (currentUser.role === "country_manager") { countries = [currentUser.country]; // ["Germany"] } else if (currentUser.role === "store_manager") { countries = [currentUser.country]; // ["United States"] cities = [currentUser.city]; // ["New York"] } const payload = { objects: { dashboards: ["sales_overview"] }, permissions: { row_based: [ { path: { dataset: "ecommerce", model: "orders", field: "country" }, operator: "is", values: countries, }, { path: { dataset: "ecommerce", model: "orders", field: "city" }, operator: "is", values: cities, }, ], }, exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60, }; ``` **What this does:** | Viewer | `countries` | `cities` | Sees | | --------------- | ------------------- | -------------- | ------------------------- | | General manager | `[]` | `[]` | All countries, all cities | | Country manager | `["Germany"]` | `[]` | Every city in Germany | | Store manager | `["United States"]` | `["New York"]` | New York only | ## Option B: Attribute-based permissions in datasets With Option A, every rule lives in your backend and names Holistics objects directly. As you embed more, this gets risky and tedious: - **Broken embeds:** if someone renames a dataset, model, or field in Holistics, the rules in your backend no longer match and your embed breaks. - **Risk of data leaks:** when you add a dashboard or a report, you have to remember to add rules for any new dataset it uses. Miss one, and viewers can see every row in that dataset. - **Extra work:** rules already set on a dataset for internal users must be rewritten in the payload, and long payloads can [exceed URL length limits](/embedded/faqs#i-received-an-error-414-request-uri-too-large-error). Option B solves this by splitting a permission into two parts, each with its own job: - **The rule: what to filter.** Lives in the Holistics modeling layer, next to your models and datasets. Managed by your Holistics admins. - **The value: who the viewer is.** Lives in your app's backend and is sent in the embed payload as a user attribute. Managed by your engineers. When the viewer opens the dashboard, Holistics fills the rule's `country` attribute with `["Germany"]` from the payload. This keeps each team on its own side: admins manage rules in Holistics, engineers only pass user attributes, and changing a rule never needs a backend update. It's also safer by default. A dataset rule applies everywhere the dataset is used, including every embed, so a viewer can't get around it because someone forgot a rule in the payload. If the payload is missing a required attribute, the viewer sees an error instead of unrestricted data. ### 1. Create the user attribute In Holistics, create a user attribute called `country`. See [User Attributes](/docs/admin/user-attributes) for how. ### 2. Add the permission rule to the dataset In the dataset, say which field to restrict and which attribute to match it against: ```aml title="ecommerce.dataset.aml" Dataset ecommerce { // ... your models and relationships ... // highlight-start permission country_access { field: r(orders.country) // the field to restrict operator: 'matches_user_attribute' value: 'country' // the user attribute from step 1 } // highlight-end } ``` **What this does:** for anyone using this dataset, Holistics only returns rows where `orders.country` matches their `country` attribute. For more options, see [Row-level permission as code](/docs/access-control/row-level-permission-as-code). ### 3. Pass the viewer's attribute in the payload When you sign the token, put the viewer's value under `user_attributes`, as an array. ```javascript const payload = { objects: { dashboards: ["sales_overview"], datasets: ["ecommerce"] }, // highlight-start user_attributes: { country: ["Germany"], }, // highlight-end exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60, }; ``` **What this does:** the viewer's `country` is `Germany`, so the dataset rule turns into `orders.country = 'Germany'`. Same result as the Option A example, with a much shorter payload. :::warning Every attribute is required If a dataset rule uses a user attribute, your payload must pass a value for it. If one is missing, the viewer sees an error such as `Missing user attributes: 'country'` instead of the embed. Besides specific values like `["Germany"]`, you can pass two special values: - `[]` (empty array): the viewer sees no rows from that dataset. - `"__ALL__"`: the rule is skipped, so the viewer sees all rows. See [below](#let-a-viewer-see-everything-with-__all__). :::
### Let a viewer see everything with `__ALL__` Some viewers, such as a CEO or a manager who covers every country, should skip the rule. Set their attribute to `__ALL__`: ```javascript const payload = { user_attributes: { country: "__ALL__", // no restriction on country }, }; ``` **What this does:** the `country_access` rule is skipped for this viewer, so they see every country. Any other attribute you pass still applies. ### Set default attribute values in an Embed Portal If you [persist your setup as an Embed Portal](/embedded/embed-portal/), you can give attributes default values at the portal level. Use this when every viewer of a portal shares the same restriction, or as a fallback when a payload leaves an attribute out. ```aml title="customer_portal.embed.aml" EmbedPortal customer_portal { objects: [ ecommerce, // dataset sales_overview, // dashboard ], // highlight-start default_user_attributes { country: ['United States'] // used when the payload doesn't set country } // highlight-end } ``` A value in the payload always wins over the portal default: ```javascript const payload = { embed_portal: "customer_portal", user_attributes: { // highlight-next-line country: ["Germany"], // overrides the default ['United States'] }, }; ``` **What this does:** this viewer sees German data. A viewer whose payload doesn't set `country` sees US data. ## How the two options combine If a payload carries both direct rules and user attributes, **all rules apply together (AND)**. The viewer only sees rows that match every rule. For example, this payload sends both. The `country` attribute feeds the `country_access` rule in the dataset, and `row_based` adds a direct rule on the same field: ```javascript title="Embed payload (your backend)" const payload = { objects: { dashboards: ["sales_overview"] }, // Option B: fills the dataset rule on orders.country // highlight-start user_attributes: { country: ["Germany", "France"], }, // highlight-end // Option A: a direct rule on the same field // highlight-start permissions: { row_based: [ { path: { dataset: "ecommerce", model: "orders", field: "country" }, operator: "is", values: ["France"], }, ], }, // highlight-end }; ``` **What the viewer sees:** - Dataset rule (from `user_attributes`): `orders.country` must be Germany or France. - Direct rule (from `row_based`): `orders.country` must be France. - Both apply (AND), so the viewer only sees **France**. Direct rules in the payload can only **narrow down** what a dataset rule allows. They can't bypass or override a rule defined in your dataset. ## FAQs ### Why does the sandbox show a different dataset name from the one in the code? The dataset was renamed. The UI shows the dataset's current title, while the code uses its unique name. The unique name is set when the dataset is created and doesn't change on rename, so existing payloads keep working. ### Do row-level permissions apply to exploration and Ask AI? Yes. They're applied at the dataset level, so they cover dashboards, data exploration, drill-down, view underlying data, user-built dashboards, email subscriptions, and Ask AI. If a viewer can reach a dataset, their permissions come with them.