From 882cb78afbc5af4ed5fc511df5d555fb412b199a Mon Sep 17 00:00:00 2001 From: javier Date: Mon, 15 Jun 2026 16:37:48 +0200 Subject: [PATCH] Add row-level security cookbook recipe --- .../sql/advanced/row-level-security.md | 156 ++++++++++++++++++ documentation/sidebars.js | 1 + 2 files changed, 157 insertions(+) create mode 100644 documentation/cookbook/sql/advanced/row-level-security.md diff --git a/documentation/cookbook/sql/advanced/row-level-security.md b/documentation/cookbook/sql/advanced/row-level-security.md new file mode 100644 index 000000000..01639c6aa --- /dev/null +++ b/documentation/cookbook/sql/advanced/row-level-security.md @@ -0,0 +1,156 @@ +--- +title: Row-level security with views and RBAC +sidebar_label: Row-level security +description: Restrict which rows each group, user, or service account can read by combining filtered views with GRANT in QuestDB Enterprise. +--- + +QuestDB's `GRANT` controls access down to the column level, but not down to +individual rows. To restrict which rows an entity can read, filter the rows +inside a view and then `GRANT SELECT` on that view. A member of a granted group +reads the view without holding any permission on the underlying table, so they +only ever see the rows the view exposes. + +:::note + +This recipe uses role-based access control (`CREATE GROUP`, `GRANT`, service +accounts), which is available in **QuestDB Enterprise** only. The queries cannot +run on the public demo instance. + +::: + +## Problem + +A single `fx_trades` table holds executions from every venue. Each trading desk +should only see its own ECN's trades, and no desk should be able to read the +full table or another desk's rows. + +## Solution + +Create one view per desk that filters `fx_trades` by `ecn`, then grant `SELECT` +on each view to the matching group. + +```questdb-sql title="One filtered view per desk" +CREATE VIEW lmax_trades AS ( + SELECT timestamp, symbol, side, price, quantity, counterparty + FROM fx_trades + WHERE ecn = 'LMAX' +); + +CREATE GROUP lmax_desk; +GRANT SELECT ON lmax_trades TO lmax_desk; +``` + +Members of `lmax_desk` can read `lmax_trades` but get "access denied" on +`fx_trades`. The grant covers the view only, and reading the view does not +require any permission on the base table: + +```questdb-sql title="What the desk can and cannot read" +SELECT * FROM lmax_trades; -- works, LMAX rows only +SELECT * FROM fx_trades; -- access denied +``` + +## Scale with a master view and an overridable filter + +Repeating the column list and join logic in every desk view is hard to +maintain. Instead, define the filtering and column selection once in a master +view whose filter is an `OVERRIDABLE` parameter, then create thin per-desk views +that pin the parameter to a fixed value. + +```questdb-sql title="Master view with an overridable filter" +CREATE VIEW desk_trades AS ( + DECLARE OVERRIDABLE @ecn := '' + SELECT timestamp, symbol, side, price, quantity, counterparty + FROM fx_trades + WHERE ecn = @ecn +); +``` + +The empty-string default means the master view returns no rows unless a caller +supplies an ECN, so it fails closed. Each desk view overrides `@ecn` with a +fixed value, and because the override is a plain (non-`OVERRIDABLE`) `DECLARE`, +callers of the desk view cannot change it: + +```questdb-sql title="Thin per-desk views that pin the filter" +CREATE VIEW lmax_trades AS ( + DECLARE @ecn := 'LMAX' + SELECT * FROM desk_trades +); + +CREATE VIEW ebs_trades AS ( + DECLARE @ecn := 'EBS' + SELECT * FROM desk_trades +); +``` + +Now grant each desk view to its group. The master view is never granted to +anyone, so it is reachable only through the pinned per-desk views: + +```questdb-sql title="Grant each desk its own view" +CREATE GROUP lmax_desk; +CREATE GROUP ebs_desk; + +GRANT SELECT ON lmax_trades TO lmax_desk; +GRANT SELECT ON ebs_trades TO ebs_desk; + +ADD USER trader_jane TO lmax_desk; +``` + +To change the exposed columns or add a join for every desk at once, edit +`desk_trades` only. The per-desk views inherit the change. + +## Why callers cannot escape the filter + +A member of `lmax_desk` holds `SELECT` on `lmax_trades` only. Attempting to +re-point the filter fails, because `lmax_trades` declares `@ecn` without +`OVERRIDABLE`: + +```questdb-sql title="The filter cannot be overridden" +-- Fails with: variable is not overridable: @ecn +DECLARE @ecn := 'EBS' SELECT * FROM lmax_trades; +``` + +The overridable `@ecn` lives on the master view, which the desk never has access +to. Querying it directly is also denied, so there is no path to the unfiltered +rows. + +## Grant to users and service accounts + +`GRANT` targets any entity, so the same views work for a named user or for a +service account backing a BI tool or dashboard: + +```questdb-sql title="Grant a view to a service account" +CREATE SERVICE ACCOUNT lmax_dashboard OWNED BY lmax_desk; +GRANT SELECT ON lmax_trades TO lmax_dashboard; +``` + +## Index the filter column with a posting index + +These views filter on a SYMBOL column. When that column is high cardinality (for +example a per-account or per-tenant identifier) and each view returns only a +small slice of the table, a +[posting index](/docs/concepts/deep-dive/posting-index/) on it speeds up the +filter. Because each view also selects a fixed set of columns, listing those +columns in an `INCLUDE` clause makes it a covering query that is served from the +index sidecar without reading the base column files: + +```questdb-sql title="Posting index covering the view's columns" +ALTER TABLE fx_trades + ALTER COLUMN ecn ADD INDEX TYPE POSTING + INCLUDE (symbol, side, price, quantity, counterparty); +``` + +The designated timestamp is appended to the `INCLUDE` list automatically. The +benefit depends on cardinality, selectivity, and the column set, so benchmark it +case by case rather than indexing by default. + +:::info Related documentation + +- [Views](/docs/concepts/views/) +- [CREATE VIEW](/docs/query/sql/create-view/) +- [DECLARE](/docs/query/sql/declare/) +- [GRANT](/docs/query/sql/acl/grant/) +- [Role-based access control (RBAC)](/docs/security/rbac/) +- [ALTER TABLE ADD INDEX](/docs/query/sql/alter-table-alter-column-add-index/) +- [Posting index](/docs/concepts/deep-dive/posting-index/) + +::: diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 995512908..f4734f2e9 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -928,6 +928,7 @@ module.exports = { "cookbook/sql/advanced/general-and-sampled-aggregates", "cookbook/sql/advanced/consistent-histogram-buckets", "cookbook/sql/advanced/array-from-string", + "cookbook/sql/advanced/row-level-security", ], }, ],