Skip to main content
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: 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.
Embedded viewers are identified by their attributes, not by a TextQL account. A viewer of a per-viewer embed link 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.

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:
Aggregate-only access is a separate file that returns counts and no patient rows:
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 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

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.

Getting support

If you have questions about row-level security or run into issues, reach out to support@textql.com or visit the customer support page.