> ## Documentation Index
> Fetch the complete documentation index at: https://docs.textql.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Row-Level Security

> Show every signed-in viewer of a Data App or dashboard only the rows they are entitled to

A Data App or dashboard is usually shared with many people, but each of them may be entitled to different rows. A provider should see their own patients, a regional manager should see their region, and a customer should see only their own account.

Row-level security in TextQL is enforced **before** the app, in governed `.tql` files in your Ontology Library. When someone views the app, every live query renders that file on the server as the person looking, with their identity, their roles, and their Library access. The app's own code only presents what comes back, so it cannot widen what a viewer sees.

## How it works

A governed `.tql` file can read the viewer's identity through runtime values. No parameters need to be declared:

| Value | What it holds |
| - | - |
| `_tql.client_attributes_json.tql_user_email` | The email of the signed-in viewer |
| `_tql.roleset_by_rolename` | The signed-in viewer's role names, or an API key's assumed roles |
| `_tql.client_attributes_json.<field>` | Fields from an API key's `clientId`, or the signed attributes of a per-viewer embed link |

These values come from the server, never from the browser, so a viewer cannot choose or alter them. The viewer also needs read and execute access to the `.tql` file in the Library. A viewer without that access is refused, even if the app's author has it.

<Note>
  Embedded viewers are identified by their attributes, not by a TextQL account. A viewer of a [per-viewer embed link](/core/guides/embedding-dashboards) has no roles, and has a `tql_user_email` only if your signed viewer assertion includes one in `client_attributes`. A session that runs on an API key carries the key's roles and the email of the key's owner, which is usually a service account rather than the person viewing. Write rules for embedded viewers on `_tql.client_attributes_json` fields, as in [Embedding Data Apps](/core/guides/embedding-data-apps).
</Note>

## Step 1: Model your entitlements

Keep a table that maps each person to what they are entitled to see: a health system, a region, a tenant, or a list of accounts. Key it on the identity you will read at query time, usually email. The table does not have to live in the same warehouse as the data it scopes, because TextQL can join across connectors.

## Step 2: Write the governed query

Read the viewer's identity, check their role, and require a matching entitlement on every path to the data. This query returns a provider's own patients and refuses anyone without the provider role:

```tql theme={null}
let
  email = _tql.client_attributes_json.tql_user_email
  is_provider = contains _tql.roleset_by_rolename "provider"
  provider_email = if is_provider then email else error "Patient-level detail requires the provider role"
in sql''
  SELECT p.patient_id, p.clinic
  FROM patients p
  WHERE p.provider_email = ${provider_email}
    AND EXISTS (
      SELECT 1 FROM entitlements e
      WHERE e.email = ${provider_email} AND e.health_system = p.health_system
    )
''
```

Aggregate-only access is a separate file that returns counts and no patient rows:

```tql theme={null}
let
  email = _tql.client_attributes_json.tql_user_email
  can_summarize = any (map (\role -> contains _tql.roleset_by_rolename role) ["provider", "system_admin"])
  viewer_email = if can_summarize then email else error "System summaries require an assigned role"
in sql''
  SELECT p.clinic, COUNT(*) AS patients
  FROM patients p
  WHERE EXISTS (
    SELECT 1 FROM entitlements e
    WHERE e.email = ${viewer_email} AND e.health_system = p.health_system
  )
  GROUP BY p.clinic
''
```

The `EXISTS` check keeps each patient counted once even if a person has more than one entitlement row for the same scope.

Write every rule so it **fails closed**:

* Reading a missing attribute fails the render, so a query that needs an identity never runs without one.
* Use `error` when a role check fails. Never fall back to an unfiltered query.
* Require a matching entitlement on every path to the underlying data.

See [fail-closed row-level scoping](/core/ontology/tql-reference#5-fail-closed-row-level-scoping) in the TQL reference for more on the pattern.

## Step 3: Use it in your app

Declare the file as a `library_tql` data source, or reference it from a compute function with `tql_path`.

A source whose `.tql`, or anything it imports, reads `_tql` is **live-only**:

* It is never baked into the app's shared snapshot, so `ana.data.get` has nothing for it.
* Read it from a compute function, either with `ana.query("source_name")` in Python or as a `tql_path` function, and call that function from the app with `ana.compute.run`.
* Render the rest of the app from the snapshot first, and hide the section that needs the viewer when nobody is signed in.

Opening an app still requires access to every connector whose data is baked into its snapshot. Connectors used only by live-only sources do not count toward that, because each live query checks the viewer's access itself.

## What each viewer sees

| Viewer | Result |
| - | - |
| A provider at Health System North | Only their own patients |
| A second provider at North | Only their own patients, never the first provider's |
| A provider at Health System South | Only South patients, never North |
| A North system administrator | Per-clinic counts for North. Patient-level detail is refused |
| Someone with a role but no entitlement row | No rows |
| A viewer of a per-viewer embed link | Rows matching their signed attributes. Role checks see no roles |
| A public share link or screenshot | Not shown, because nobody is signed in |
| A scheduled job that runs as the app's creator | The job runs with `ana.viewer` set to `None`, and any query that reads the viewer's identity is refused |
| A job that runs for each recipient | Each recipient's own rows |
| An API key scoped to fewer roles than its owner | The key's roles only, never the owner's full set |

## Where entitled rows never appear

* The app's shared snapshot and the cached copies built from it.
* Public share links and screenshots.
* Queries that read the viewer's identity inside scheduled jobs and refresh hooks that run as the app's creator. Those queries are refused.

Caches that serve a single viewer, described below, may hold that viewer's own rows. They are never shared with another viewer.

## Dashboards

Dashboards follow the same rules. Live queries from dashboard code with `ana.query` render as the viewer, and a source that reads `_tql` is not preloaded as a `dashboard_<name>` DataFrame. Results cached by `ana.query` and by `@st.cache_data` or `@st.cache_resource` functions are keyed to each viewer, so one viewer's cached rows are never returned to another.

## Revoking access

A change to someone's roles or entitlement rows takes effect the next time their query runs on the server. Results that are already cached can still be served until they expire:

* In an open dashboard, `ana.query` results expire within 1 minute and cached functions within 5 minutes. This applies to role changes as well as entitlement changes.
* In a Data App with its query cache turned on, a change to the entitlement table can take up to the cache lifetime set for the app, at most 15 minutes. Roles are applied when the query renders, before that cache is consulted, and the rendered SQL is part of the cache key, so a role change applies on the next query.

## Testing your rules

Before rolling an app out, check it as each kind of viewer:

1. Sign in as a person from each scope (for example, one provider per health system) and confirm each sees only their own rows.
2. Sign in as someone with a role but no entitlement row, and confirm they see nothing.
3. Sign in as someone entitled only to aggregates, and confirm row-level detail is refused.
4. Open a public share link, and confirm the protected section is hidden.
5. Run any scheduled jobs, and confirm none of them write entitled rows into shared output.

## Related

* [Embedding Data Apps](/core/guides/embedding-data-apps) for per-user security in apps embedded in your own product.
* [Embedding Dashboards](/core/guides/embedding-dashboards) for per-viewer embed links.
* [TQL Reference](/core/ontology/tql-reference#runtime-context) for every runtime value.

## Getting support

If you have questions about row-level security or run into issues, reach out to [support@textql.com](mailto:support@textql.com) or visit the [customer support page](/core/get-started/customer-support).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.