You’re building a SaaS analytics platform. Fifty customers, each with their own data, each expecting isolation. The naive approach is one table per tenant. That works until you have 200 tenants and 50 dimension tables each, and you’re managing 10,000 tables with identical schemas, each needing its own grants, its own ETL pipeline, and its own monitoring.
The alternative is multi-tenancy on shared tables. One physical orders table with a tenant_id column. One customers table. One pipeline. The security layer ensures tenant A never sees tenant B’s rows, and PII columns are masked based on the caller’s role.
Unity Catalog makes this viable with row filters and column masks. These aren’t views that someone can bypass by querying the underlying table – they’re enforced at the engine level, on every query, regardless of how the table is accessed.
This article builds the entire stack: tenant isolation with row filters, PII masking with column masks, attribute-based access control using groups and tags, and the audit trail to prove it works.
Groups are the bridge between users and tenant access. Each tenant gets a group. Roles get separate groups.
Databricks does not support CREATE GROUP or ALTER GROUP SQL statements. Groups are managed via the SCIM API (or synced from your IdP). Use the Databricks SDK or REST API as shown below.
fromdatabricks.sdkimportWorkspaceClientw=WorkspaceClient()# Create tenant groupsfortenantin["tenant_acme","tenant_globex","tenant_initech"]:w.groups.create(display_name=tenant)# Create role groupsforrolein["platform_admins","data_ops"]:w.groups.create(display_name=role)# Add users to groups (replace with your actual usernames)defadd_user_to_group(group_name:str,user_email:str):groups=list(w.groups.list(filter=f'displayName eq "{group_name}"'))users=list(w.users.list(filter=f'userName eq "{user_email}"'))ifgroupsandusers:w.groups.patch(groups[0].id,operations=[{"op":"add","path":"members","value":[{"value":users[0].id}]}])add_user_to_group("tenant_acme","[email protected]")add_user_to_group("tenant_globex","[email protected]")add_user_to_group("platform_admins","[email protected]")add_user_to_group("data_ops","[email protected]")
Row filters are SQL functions that return TRUE for rows the caller is allowed to see. The function has access to current_user() and is_account_group_member().
1
2
3
4
5
6
7
8
9
10
-- Row filter: only show rows for the caller's tenant
CREATEORREPLACEFUNCTIONsaas_platform.security.tenant_filter(tenant_idSTRING)RETURNSBOOLEANRETURN-- Platform admins see everything
is_account_group_member('platform_admins')OR-- Tenant users see only their tenant's rows
is_account_group_member(concat('tenant_',tenant_id));
Apply the filter to the tables:
1
2
3
4
5
6
7
8
9
-- Apply row filter to orders
ALTERTABLEsaas_platform.shared.ordersSETROWFILTERsaas_platform.security.tenant_filterON(tenant_id);-- Apply row filter to customers
ALTERTABLEsaas_platform.shared.customersSETROWFILTERsaas_platform.security.tenant_filterON(tenant_id);
Now test it:
1
2
3
4
5
6
7
8
9
10
11
-- As an acme_analyst user:
SELECTcurrent_user(),count(*)FROMsaas_platform.shared.orders;-- Returns: [email protected], 3
-- As a globex_analyst user:
SELECTcurrent_user(),count(*)FROMsaas_platform.shared.orders;-- Returns: [email protected], 2
-- As a platform_admin user:
SELECTcurrent_user(),count(*)FROMsaas_platform.shared.orders;-- Returns: [email protected], 7
The filter is invisible to the caller. They query the table normally and only see their rows. There’s no WHERE tenant_id = 'acme' they need to remember. The engine enforces it.
-- Mask email: show first char + domain, hash the rest
CREATEORREPLACEFUNCTIONsaas_platform.security.mask_email(emailSTRING)RETURNSSTRINGRETURNCASEWHENis_account_group_member('data_ops')THENemailELSEregexp_replace(email,'(.).*@','\\1***@')END;-- Mask phone: show last 4 digits
CREATEORREPLACEFUNCTIONsaas_platform.security.mask_phone(phoneSTRING)RETURNSSTRINGRETURNCASEWHENis_account_group_member('data_ops')THENphoneELSEconcat('***-',right(phone,4))END;-- Mask SSN: always mask for non-data_ops, show only last 4
CREATEORREPLACEFUNCTIONsaas_platform.security.mask_ssn(ssnSTRING)RETURNSSTRINGRETURNCASEWHENis_account_group_member('data_ops')THENssnELSEconcat('***-**-',right(ssn,4))END;
Step 5: Attribute-Based Access Control with Tags
#
For more complex policies, use Unity Catalog tags as attributes. Tags let you attach metadata to columns and build policy logic that references those tags.
1
2
3
4
5
6
7
8
9
10
11
12
-- Tag sensitive columns
ALTERTABLEsaas_platform.shared.customersALTERCOLUMNemailSETTAGS('sensitivity'='pii');ALTERTABLEsaas_platform.shared.customersALTERCOLUMNphoneSETTAGS('sensitivity'='pii');ALTERTABLEsaas_platform.shared.customersALTERCOLUMNssnSETTAGS('sensitivity'='highly_restricted');ALTERTABLEsaas_platform.shared.customersALTERCOLUMNtierSETTAGS('sensitivity'='internal');
Query the information schema to discover what’s sensitive:
1
2
3
4
5
6
7
8
9
10
11
12
-- Find all PII columns across the catalog
SELECTtable_catalog,table_schema,table_name,column_name,tag_name,tag_valueFROMsystem.information_schema.column_tagsWHEREtag_name='sensitivity'ANDtag_valueIN('pii','highly_restricted')ORDERBYtable_name,column_name
This is policy-as-code. Instead of manually tracking which columns are sensitive in a spreadsheet, the tags are attached to the columns themselves. Auditors can query the information schema to verify that all PII columns have masks applied.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- Compliance check: PII columns without masks
SELECTct.table_name,ct.column_name,ct.tag_valueASsensitivity_level,CASEWHENcm.function_nameISNOTNULLTHEN'MASKED'ELSE'UNMASKED'ENDASmask_statusFROMsystem.information_schema.column_tagsctLEFTJOINsystem.information_schema.column_maskscmONct.table_catalog=cm.table_catalogANDct.table_schema=cm.table_schemaANDct.table_name=cm.table_nameANDct.column_name=cm.column_nameWHEREct.tag_name='sensitivity'ANDct.tag_valueIN('pii','highly_restricted')ORDERBYmask_status,ct.table_name
Any row in this query where mask_status = 'UNMASKED' is a compliance gap that needs to be fixed.
-- Execute as acme user: try to see globex data
-- (This should return 0 rows, not an error)
SELECT*FROMsaas_platform.shared.ordersWHEREtenant_id='globex';-- Returns: 0 rows (the filter excludes them before the WHERE)
The important point: the row filter runs before the user’s WHERE clause. Even if someone explicitly queries for another tenant’s tenant_id, they get nothing. There’s no error message that leaks the existence of other tenants.
-- Can a tenant infer how many other tenants exist?
SELECTcount(DISTINCTtenant_id)FROMsaas_platform.shared.orders;-- Returns: 1 (only their own tenant_id is visible)
Look for the filter function in the scan plan. If it appears at the scan level (not as a post-filter), the engine is pruning rows early, which means full table scans don’t pay the cost of reading every tenant’s data.
-- Who accessed what, and did the filter apply?
SELECTuser_identity.email,request_params.commandText,event_time,response.status_codeFROMsystem.access.auditWHEREaction_name='commandSubmit'ANDrequest_params.commandTextLIKE'%shared.orders%'ORDERBYevent_timeDESCLIMIT50
# Example: onboard a new tenant programmaticallyfromdatabricks.sdkimportWorkspaceClientw=WorkspaceClient()defonboard_tenant(tenant_id:str,admin_email:str):"""Create tenant group and add initial admin user."""group_name=f"tenant_{tenant_id}"# Create groupgroup=w.groups.create(display_name=group_name)# Add user to groupuser=w.users.list(filter=f'userName eq "{admin_email}"')user_list=list(user)ifuser_list:w.groups.patch(group.id,operations=[{"op":"add","path":"members","value":[{"value":user_list[0].id}]}])print(f"Tenant {tenant_id} onboarded with group {group_name}")returngrouponboard_tenant("newcorp","[email protected]")
The row filter function (tenant_filter) automatically works for the new tenant because it uses concat('tenant_', tenant_id) – no filter update needed.
Row filters add latency to every query. With 200 tenants, the is_account_group_member() check needs to evaluate group membership for the caller. In practice:
Group membership is cached per session, so the cost is paid once
Filter pushdown means the engine prunes rows at the scan level, not after reading everything
Partition by tenant_id if queries are consistently single-tenant:
1
2
3
4
5
-- Partition by tenant_id for scan pruning
ALTERTABLEsaas_platform.shared.ordersSETTBLPROPERTIES('delta.autoOptimize.optimizeWrite'='true');OPTIMIZEsaas_platform.shared.ordersZORDERBY(tenant_id);
Z-ordering by tenant_id clusters each tenant’s data together on disk, so the row filter prunes at the file level rather than the row level.
Row filter vs. view performance. Row filters run inside the engine and benefit from predicate pushdown and file pruning. Dynamic views (WHERE is_account_group_member(...)) achieve similar results but can’t be pushed down the same way. For performance-critical multi-tenant workloads, row filters are the better choice.
Nested function limitations. Row filter and column mask functions can’t call other UDFs that reference table data. They can use built-in functions (current_user(), is_account_group_member(), IF, CASE) but not SELECT subqueries. If your policy logic is too complex for a single expression, simplify or precompute the mapping into the groups.
Grant inheritance with metastore bindings. When a table has a row filter, SELECT privilege on the table still goes through the filter. But MODIFY privilege bypasses row filters for writes. Be careful who has write access – a user with MODIFY can insert rows for any tenant, and the filter only protects reads.
Testing in development. You can’t easily impersonate another user to test filters. Options: (1) create test users in each tenant group, (2) use EXECUTE AS in notebooks (limited support), or (3) write integration tests that connect as different service principals. Option 3 is the most reliable for CI/CD.
Column mask + aggregation. Masks apply before aggregation. If you AVG(ssn) with a mask that returns ***-**-XXXX, you get nonsense. Make sure masked columns aren’t used in numeric aggregations downstream. This is a design constraint, not a bug.
Multi-tenancy on a shared lakehouse doesn’t require table-per-tenant sprawl. Unity Catalog’s row filters enforce tenant isolation at the engine level – invisible to the caller, impossible to bypass through SQL. Column masks handle PII protection with role-based logic. Tags turn your column metadata into a queryable policy system.
The pattern scales because onboarding a new tenant means creating a group, not cloning tables. The filter function resolves group membership dynamically. The audit trail proves isolation to compliance teams without custom logging.
This is how you build a data platform for 200 tenants with the operational overhead of managing one set of tables.
Author
Mariusz Derela
Cyber Security Specialist | DevSecOps | AI/ML
Databricks Challenge -
This article is part of a series.