Row-Level Security in Databricks Unity Catalog for GIS Data
This recipe implements row-level security on a spatial Delta table in Databricks Unity Catalog by attaching a SQL ROW FILTER function that restricts rows to a caller’s tenant and bounding region, plus a COLUMN MASK that coarsens geometry precision for unprivileged principals, and verifies both from an ordinary session.
Context and prerequisites
Unity Catalog exposes declarative row filters and column masks as first-class SQL objects: a filter is a UDF returning BOOLEAN that Databricks evaluates per row before results leave the engine, and a mask is a UDF that transforms a single column’s value in flight. Unlike a secured view, these bind to the base table with ALTER TABLE, so they cannot be sidestepped by querying the table directly. This page extends Security Boundaries for GIS Data and pairs with the engine-agnostic treatment in implementing row-level security for geospatial datasets. You need a Unity Catalog metastore, a Databricks Runtime with the built-in ST_* SQL spatial functions (DBR 17.1+ / SQL warehouses with geospatial functions enabled), and permission to create functions in the target schema. Geometry is stored in a Delta table using the native GEOMETRY type with SRID 4326.
Complete working solution
Create the spatial Delta table, a group-to-region mapping table, then the row filter and column mask functions, and finally bind them with ALTER TABLE.
-- Databricks SQL (Unity Catalog, DBR 17.1+); geometry as native GEOMETRY, EPSG:4326
CREATE CATALOG IF NOT EXISTS gis_prod;
CREATE SCHEMA IF NOT EXISTS gis_prod.assets;
CREATE TABLE gis_prod.assets.spatial_assets (
asset_id BIGINT,
tenant_id STRING NOT NULL,
geom GEOMETRY(4326),
label STRING
)
USING DELTA
PARTITIONED BY (tenant_id)
TBLPROPERTIES (delta.enableDeletionVectors = true);
-- Which group may see which tenant, and the bounding polygon it is scoped to.
CREATE TABLE gis_prod.assets.tenant_grants (
group_name STRING NOT NULL,
tenant_id STRING NOT NULL,
region_wkt STRING NOT NULL -- WKT polygon, EPSG:4326
);
INSERT INTO gis_prod.assets.tenant_grants VALUES
('west_analysts', 'acme_west',
'POLYGON((-123 37, -121 37, -121 39, -123 39, -123 37))'),
('east_analysts', 'acme_east',
'POLYGON((-74 40, -72 40, -72 42, -74 42, -74 40))');
INSERT INTO gis_prod.assets.spatial_assets VALUES
(1, 'acme_west', ST_GeomFromText('POINT(-122.4 37.8)', 4326), 'sf_pump'),
(2, 'acme_west', ST_GeomFromText('POINT(-121.9 38.5)', 4326), 'delta_valve'),
(3, 'acme_east', ST_GeomFromText('POINT(-73.9 40.7)', 4326), 'nyc_meter');
-- Row filter: TRUE only when the caller belongs to a group whose grant
-- both matches the row's tenant and contains the row's geometry.
CREATE OR REPLACE FUNCTION gis_prod.assets.rls_tenant_region(row_tenant STRING, row_geom GEOMETRY)
RETURN
is_account_group_member('gis_admins')
OR EXISTS (
SELECT 1
FROM gis_prod.assets.tenant_grants g
WHERE is_account_group_member(g.group_name)
AND g.tenant_id = row_tenant
AND ST_Contains(ST_GeomFromText(g.region_wkt, 4326), row_geom)
);
-- Column mask: full geometry for admins, snapped-to-grid geometry otherwise.
CREATE OR REPLACE FUNCTION gis_prod.assets.mask_geom(g GEOMETRY)
RETURN CASE
WHEN is_account_group_member('gis_admins') THEN g
ELSE ST_GeomFromText(
concat('POINT(', cast(round(ST_X(g), 1) AS STRING), ' ',
cast(round(ST_Y(g), 1) AS STRING), ')'), 4326)
END;
-- Bind both policies to the base table.
ALTER TABLE gis_prod.assets.spatial_assets
SET ROW FILTER gis_prod.assets.rls_tenant_region ON (tenant_id, geom);
ALTER TABLE gis_prod.assets.spatial_assets
ALTER COLUMN geom SET MASK gis_prod.assets.mask_geom;
After the two ALTER TABLE statements, every reader of spatial_assets — dashboards, notebooks, JDBC clients — transparently receives filtered rows with masked coordinates, no view required.
Step-by-step walkthrough
-
Native
GEOMETRY(4326)column. Storing geometry with an explicit SRID lets Databricks apply spatial functions without per-query parsing and lets the optimizer keep spatial statistics. Partitioning bytenant_idgives Delta a coarse pruning key that the row filter’s tenant equality can exploit. -
Policy driven by
tenant_grants. Rather than hardcoding group logic, the mapping table joins a Unity Catalog account group to a tenant and a bounding polygon. Adding a customer is anINSERT; the filter picks it up on the next query with no redeploy. -
is_account_group_memberfor identity. Unity Catalog resolves the caller’s group membership server-side. The filter returnsTRUEwhen an admin, or when the caller is in a granted group whose row matches on tenant and whose polygonST_Containsthe row geometry. The two spatial and tenant conditions are ANDed, so a west analyst cannot see east rows even if a future grant row misconfigures the tenant. -
Filter signature matches
ON (...). The function parameters(row_tenant, row_geom)are bound positionally toON (tenant_id, geom). Databricks passes those two columns into the filter for every candidate row; the function must returnBOOLEAN. -
Column mask coarsens precision. Even authorized-to-see-the-row analysts should not always get survey-grade coordinates.
mask_geomreturns the full geometry for admins and otherwise snaps to one decimal degree (~11 km), a defense against coordinate-precision leakage. Masks operate independently of the row filter and stack with it. -
ALTER TABLE ... SET ROW FILTER / SET MASK. This is the binding step. Because it modifies the table object, not a view, there is no unfiltered path to the data for principals who only holdSELECT. Dropping a policy isALTER TABLE ... DROP ROW FILTER. The full grammar lives in the Unity Catalog row filters and column masks documentation.
Common errors and fixes
| Error | Cause | Fix |
|---|---|---|
The row filter function must return BOOLEAN |
Function body returns a non-boolean (e.g. a GEOMETRY) |
Ensure the top-level expression evaluates to TRUE/FALSE; wrap spatial checks in EXISTS/ST_Contains |
Number of arguments does not match on ALTER TABLE |
ON (...) column list length differs from the function’s parameters |
Align the ON columns with the function signature order and arity |
| All rows disappear for every user | No matching tenant_grants row, or caller not in any listed group |
Verify is_account_group_member group names exactly match account groups (case-sensitive) |
| Mask throws on NULL geometry | ST_X/ST_Y called on a NULL geom |
Add WHEN g IS NULL THEN NULL as the first CASE branch |
Verification
Validate as a non-admin member of west_analysts. The filter should hide the east tenant and the mask should blunt coordinate precision, all without any WHERE clause.
-- Run as a user in group west_analysts (not gis_admins)
SELECT asset_id, label, ST_AsText(geom) AS geom_txt
FROM gis_prod.assets.spatial_assets
ORDER BY asset_id;
-- Expected: only asset_id 1 and 2. Coordinates rounded, e.g. POINT(-122.4 37.8).
-- asset_id 3 (acme_east) never appears.
-- Negative probe: explicit cross-tenant request still returns nothing.
SELECT count(*) AS leaked
FROM gis_prod.assets.spatial_assets
WHERE tenant_id = 'acme_east';
-- leaked = 0
-- Confirm the policies are bound.
DESCRIBE EXTENDED gis_prod.assets.spatial_assets;
-- Look for a "Row Filter" line naming rls_tenant_region and a masked geom column.
A leaked count of 0 on the explicit east probe is the proof that the row filter is enforced at the table, not layered as a bypassable view. Re-running the same query as a gis_admins member returns all three rows with full-precision geometry, confirming the policy branches on identity.
The Databricks model is declarative where Trino’s is provider-driven; for the open-source counterpart that injects the same predicate through the access control SPI, see row-level security for spatial data in Trino, and consult the Databricks geospatial functions reference for the full ST_* catalog.