Row-level security with profile tags and data tags
Same report, different users, different rows. RLS is Layer 3 — the last layer most teams ever need. Two mechanisms (profile tags and data tags) for two different shapes of "who sees what."
By the end of this lesson
- An RLS policy that filters one report's rows per user
- Working profile-tag and data-tag rules, with safe-fallback defaults
- A repeatable pattern you can apply to more reports and more apps
Background
Layer 3 of the three-layer model. Layer 1 (create users) decides whether a user can touch an app at all. Layer 2 (groups & folders) decides which reports inside those apps they can open. Layer 3 — this lesson — decides which rows of data appear inside those reports.
No row-level security by default. Any user with access to an app and a folder sees every row those reports return. If your team should see all the data, skip this lesson. RLS is for "same report, different users, different rows showing" — the classic case is a sales report where each rep sees only their region.
Two mechanisms, one outcome
RLS works by injecting a filter into the SQL DashboardFox generates. The filter references a tag value that's specific to the current user. Two ways to attach values to users:
Picking between them: are most of your security requirements per-individual, or per-group? If each user has a unique value (their own region, their own client list), profile tags. If users share values within audiences (every Acme employee sees Acme's data), data tags. You can use both in the same instance — different rules can use different mechanisms.
Whichever mechanism supplies the value, a data security policy (in Settings → Security → Data Security) is where you wire it into a SQL constraint. Both paths converge there.
Stuck on which mechanism to pick, or on a policy that isn't filtering rows the way you expected? Email team@dashboardfox.com with the report you're trying to filter, the rule you're trying to express, and a screenshot of your data security policy. Real human, same business day.
Before you start
- The field you'll filter on. Pick a column in your semantic layer that maps to "what this user should see" — customer name, region, assignee, tenant ID. The constraint will reference this field.
- A decision: profile tag or data tag. If unique per user, profile tag. If shared within audiences, data tag. (Mixed setups are fine — different rules can use different mechanisms.)
- Default value strategy. For data tags, decide what users without an explicit value should see — empty (they see nothing), a demo dataset, a safe fallback.
- At least one test user per scope. RLS verification is meaningless without logging in as the people who should see different things.
- Login/logout discipline. Policy changes only take effect on the user's next sign-in — keep a "log out, log back in" step in your test loop.
Do it
-
Identify the report and the field to filter on
Pick an existing report (or build a quick one — covered in the Builder module) that returns rows you want to scope per user. The classic shape: a report with a customer or assignee column whose values should be filtered to the current user's scope.
The constraint will apply at the category/report-type level, not per-report — meaning every report in that category, including future ones, will inherit the filter. That's a feature, not a limit. It's why "Composers can build new reports and RLS still applies" works.
-
Path A — Profile tag (per-user values)
Use this when each user has a unique value, or when users are auto-provisioned via the DashboardFox API (profile tag values can be set during user provisioning).
Step A1 — Set the profile tag value on each user. Edit each user (Settings → Security → Users → Edit). The fields that double as profile tags:
- Company — most common for customer/tenant scoping. Referenced as
/#company#/. - tag1 through tag4 — general-purpose slots in the Additional Options accordion. Referenced as
/#tag1#/, etc. - firstname, lastname, Login, email, description — also referenced as profile tags. Auto-populated from General Information.
Set the value (e.g., Company =
Acme Corp). Save. Repeat for each user who should be scoped.Step A2 — Create the data security policy. Settings → Security → Data Security → Create New Policy. Name it descriptively (e.g.,
Customer Restriction). Description optional.Step A3 — Add a constraint. Click Add Constraint:
- Select Category — pick your app's semantic-layer category (report type).
- Select Fields — pick the field to constrain (e.g., Customer).
- Operator — defaults to
=(equal). Use this for single-value scoping. - Custom Value — scroll to the bottom of the value dropdown or type
custom. Enter/#company#/(the profile tag reference). - Click + Add Constraint.
Step A4 — Assign the policy to users or groups. Scroll to Assigned Groups.
- Recommended default: All Users. Every user gets filtered by their own Company value automatically — clients see only their client's rows, and admins (who typically don't have a Company value matching client data) see zero rows on this report. That's fine: admins use admin-only reports against admin-only folders where this RLS policy doesn't apply.
- Alternative: scope to specific groups (every client group, but not Administrators). Use this only when admins regularly need to see unfiltered client data from the same reports clients run. The tradeoff: every new client group you add must be remembered and added to this policy's assignments. All Users covers them automatically.
Save.
Step A5 — Verify. Sign out, log in as each test user. They should see only rows matching their Company value. Any user without a Company set sees nothing — that's working as intended.
- Company — most common for customer/tenant scoping. Referenced as
-
Path B — Data tag (group/tenant values with default)
Use this when multiple users share a value (everyone in the Acme group sees Acme's data), or when you want a safe-fallback default for users without an explicit value.
Step B1 — Create the data tag. Settings → Security → Data Tags → Create New Data Tag:
- Label — the name you'll reference. Stick with snake_case lowercase (e.g.,
assignee,region_code) and avoid spaces. The reference syntax will be/#assignee#/. - Group — an optional free-text label for organizing your data tags in the list view. Cosmetic only; doesn't change behavior.
- Data Type — Character, Numeric, or Datetime. Match the type of the field you'll constrain.
- Default Value — what users without an explicit value see. Powerful: set this to a demo/safe value (e.g.,
DEMO_CUSTOMER) so admin users and unmapped users see something meaningful rather than nothing.
Step B2 — Assign values per group and/or user. Below the basic fields:
- Group Assignments — every group is listed. Check the box and enter the value for each group that should be scoped. Members of that group inherit the value.
- User Assignments — for users who need an override or a value outside of groups. Search and assign.
Save. Notice you can toggle the data tag inactive from the list view — useful for testing.
Step B3 — Create or extend the data security policy. Either create a new policy or add to an existing one (we'll cover the AND-vs-OR composition implications shortly). Add a constraint:
- Select Category, Select Fields — same as Path A.
- Operator — usually
=for single-value scoping, or in list for multi-value scoping (e.g., a manager seeing multiple regions). - Custom Value — enter
/#assignee#/(or whatever label you used). - Add the constraint.
Step B4 — Assign and verify. Save the policy. Sign out, sign in as test users. Members of an assigned group see their group's value; users without an assignment see the default value (or nothing, if no default is set).
- Label — the name you'll reference. Stick with snake_case lowercase (e.g.,
-
AND vs OR — keep constraints in one policy
The single most consequential decision when an app needs more than one constraint:
✓ Same policy → ANDMultiple constraints inside one policy combine as AND — the most restrictive interpretation. Company matches AND assignee matches.
Almost always what you want. Keep all an app's constraints in one policy and add them inside that policy.
✗ Separate policies → ORConstraints in separate policies on the same app combine as OR — the most permissive interpretation. Company matches OR assignee matches.
Almost never what you want. Users see more data than either policy alone would allow — the union of all matches.
Rule of thumb: one policy per app. Add every constraint that app needs as a constraint inside that policy. Test by toggling individual constraints inactive (the per-constraint toggle is right there in the policy editor) rather than splitting policies for testing.
-
In-list values — the syntax that catches everyone
For an operator of in list (multiple acceptable values), the custom-value syntax has a specific format that's easy to mistype:
In-list custom-value formatUse this exact pattern:
value1','value2','value3','value4
- No leading single quote on the first value
- No trailing single quote on the last value
- No spaces anywhere — not after the commas, not around the separators
- The separator between values is exactly
','(close-quote, comma, open-quote)
DashboardFox wraps your input in single quotes automatically — that's why the leading and trailing ones are missing. Type extras and the generated SQL ends up with
''value1'', which never matches.For mixing static values and dynamic tags in an in-list (e.g., "this customer plus DEMO_DATA fallback"), you can include the tag reference:
/#company#/','DEMO_DATA. -
The complete tag syntax reference
Reference for every place a tag value can be inserted in a data security policy constraint.
Open the full reference
Profile tags (built-in, attached to every user):
/#firstname#/— user's first name/#lastname#/— user's last name/#Login#/— user's login (case-sensitive in the reference)/#email#/— user's email address/#company#/— Company field from General Information/#description#/— Description field from General Information/#tag1#/,/#tag2#/,/#tag3#/,/#tag4#/— the four general-purpose slots in Additional Options → Profile Tags
Data tags (custom, defined in Settings → Security → Data Tags):
/#yourlabel#/— whereyourlabelmatches the Label you set when creating the data tag. Recommended: snake_case lowercase, no spaces.- Reserved names you cannot use as data tag labels:
firstname,lastname,Login,email,company,description,tag1,tag2,tag3,tag4(these are already profile tags).
Where to paste the syntax:
- In the Custom Value field of a constraint, with
=operator: paste/#tagname#/directly. - In the Custom Value field with
in listoperator: use the in-list format from the previous step. Can mix tags and literal values:/#company#/','DEMO_DATA.
-
RLS follows the user everywhere
One of the most useful properties of how RLS is built: the constraint applies at the semantic-layer category level, not on individual reports. Consequences worth knowing:
- New reports inherit the filter. A Composer building a new report on the same category will see filtered data themselves and produce reports that filter correctly for every other user. They can't accidentally build an unfiltered report.
- Dashboards inherit the filter. Reports inside dashboards filter per the dashboard viewer's tag values. The same dashboard shown to two users shows two different slices.
- Scheduled exports inherit the filter. The recipient's tag values determine what their copy of the report contains.
- Drill-downs inherit the filter. Click-through detail views still respect the RLS constraint.
- Admins are not bypassed. An admin without a matching tag value sees nothing — same as any other user. This is by design and is auditable (admin changes to RLS policies appear in the audit log).
The mechanism is the dynamic SQL DashboardFox writes; the filter is baked into the WHERE clause every time. There's no separate "RLS-aware report" toggle to remember.
-
Always log out and back in to test
Policy and tag changes don't take effect mid-session. After every change you want to verify, log out and back in as the test user. Until they re-authenticate, they're still operating with the cached values from their last session.
The most common "I changed the policy and nothing happened" report traces back to this. Build the log-out-log-in step into your verification loop and you'll save yourself a lot of confusion.
You have a working connection. Below is what to tighten before real users see it. Skip if you're just testing.
Make it real
Patterns and habits that hold up as your RLS grows from one rule to many.
Set safe-fallback defaults on every data tag
The default-value feature on data tags is the most under-used feature in this lesson. Set every data tag's default to a known-safe value — demo data, a placeholder customer, anything that's harmless to expose. Then any user without an explicit assignment (a new admin, a misconfigured user) sees the safe default instead of nothing or, worse, an unfiltered result.
Two common default strategies: (1) demo data — a customer/tenant that exists in your data but contains only sample/safe rows; (2) impossible value — e.g., NO_MATCH — which guarantees zero rows. Pick whichever fits your data shape.
Keep all an app's constraints in one policy
One policy per app. Add every constraint inside that policy. This guarantees AND composition (the most restrictive interpretation). Splitting into separate policies on the same app gives you OR composition — almost always more permissive than intended.
Use the per-constraint toggle (active/inactive) inside the policy when you want to disable a rule for testing or temporary loosening — don't create a second policy just to toggle.
Set Company on every user during creation
Repeated from lesson 2 because it matters here: filling in Company during user creation is four seconds. Backfilling 50 users when you wire up the Customer Restriction policy is a tedious afternoon. Same goes for tag1–tag4 if you'll use them for region or tenant scoping.
Audit-log review on RLS policy changes
Admin changes to data security policies and data tags appear in the audit log (covered in Module 6). Review periodically — particularly after onboarding new admins. It's the only way to catch "an admin loosened a policy and didn't tell anyone."
Verify as three personas, every time
Every RLS change deserves a three-persona verification: (1) the scoped end user (sees only their slice), (2) a different scoped user (sees their different slice — confirms it's not just "all show X"), (3) a user with no assignment (sees the default, or nothing). Five minutes per change; saves hours of "the customer noticed first" debugging.
For most teams, three layers of security is enough — you're done with Module 4. If you're running multi-tenant (distinct customer organizations sharing one instance) or you want to lock down composer destructive actions, continue with tenant mode.
Common patterns playbook
Sales rep sees their region
Profile tag. Set tag1 = region code on each user. Constraint: Region field = /#tag1#/. Applied to All Users.
Each customer sees their data
Data tag. One tag (e.g., customer_id) with default DEMO. Per-group value assignment. Constraint on every relevant report category.
Manager sees their team
Profile tag tag2 = team identifier. Constraint with in list operator for multi-team managers: /#tag2#/','TEAM_SHARED.
Demo data for guests
Data tag with default = a safe demo customer ID. Unmapped users (or guest-library viewers) see only the demo data; real users see their own.
If you're stuck
What goes wrong with RLS, in roughly the order it shows up.
My admin sees no data after I applied the policy
The admin doesn't have a Company (or tag1, or whatever the policy references) set on their user record. Working as designed — admins aren't bypassed. Fix: either (1) set the value on the admin user, (2) set a safe default on the data tag, or (3) scope the policy to specific groups rather than All Users, excluding admins.
I applied to All Users but RLS isn't filtering
The user hasn't logged out and back in. Policy changes take effect on next sign-in. Have them log out fully and back in, then re-run the report.
Reference syntax errors out
Confirm the format is /#name#/ — forward-slash, hash, name, hash, forward-slash. Common mistakes: backslashes instead of forward slashes, missing leading or trailing slash, capitalized tag name where the original is lowercase. For built-in profile tags, Login is the one that's intentionally capitalized; everything else is lowercase.
My in-list values aren't matching anything
You probably included single quotes around the first or last value, or added spaces. The exact format: value1','value2','value3 — no leading tick, no trailing tick, no spaces, separator is ',' (close-quote, comma, open-quote).
Different groups need different rules — can I do that?
Yes — either (1) use data tags with per-group values inside one constraint, or (2) scope separate constraints to different groups by adjusting the Assigned Groups on the policy. Two policies on the same app gives you OR composition (likely too permissive) — favor one policy with group-scoped values.
Composer can still see all data when building new reports
The constraint is on a different category than the one they're building against. Constraints apply per category. Add a constraint to the policy for each category in the app the Composer can reach.
One report filters correctly but another in the same app doesn't
Same cause as above. The policy has a constraint on the first report's category but not the second's. Add a constraint for each category that needs scoping. One policy can hold many constraints across many categories.
None of these match my situation
Email team@dashboardfox.com with the report, the policy (screenshot or description), the test user's tag values, and what you expected vs. what you saw. Same business day reply.
