Row-level security with views and RBAC
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.
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.
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:
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.
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:
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:
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:
-- 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:
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 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:
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.