Skip to main content

Multi-Tenant Data Platform: Row Filters, Column Masks, and ABAC on Unity Catalog

Table of Contents
Databricks Challenge - This article is part of a series.
Part : This Article

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.


Architecture
#

flowchart TB
    subgraph Users["Tenant Users"]
        U1["Acme Corp user"]
        U2["Globex user"]
        U3["Platform Admin"]
    end

    subgraph UC["Unity Catalog"]
        RF["Row Filter
(tenant isolation)"] CM["Column Mask
(PII protection)"] GR["Groups
(tenant_acme, tenant_globex,
data_ops, platform_admins)"] end subgraph Tables["Shared Physical Tables"] T1["shared.orders"] T2["shared.customers"] end U1 --> GR U2 --> GR U3 --> GR GR --> RF & CM RF --> T1 & T2 CM --> T1 & T2 style RF fill:#e74c3c,color:white style CM fill:#3498db,color:white style GR fill:#2ecc71,color:white

The security model has three components:

  1. Row filters – SQL functions that resolve the caller’s tenant membership and filter rows accordingly
  2. Column masks – SQL functions that redact or hash PII based on the caller’s role
  3. Groups – the mapping between users and their tenant/role attributes

No views, no per-tenant tables, no application-layer filtering that someone can forget to apply.


Step 1: Set Up the Multi-Tenant Schema
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
CREATE CATALOG IF NOT EXISTS saas_platform;
CREATE SCHEMA IF NOT EXISTS saas_platform.shared;
CREATE SCHEMA IF NOT EXISTS saas_platform.security;

-- Shared orders table
CREATE TABLE IF NOT EXISTS saas_platform.shared.orders (
    order_id STRING,
    tenant_id STRING,
    customer_id STRING,
    customer_email STRING,
    customer_phone STRING,
    product STRING,
    amount DOUBLE,
    order_date DATE,
    region STRING
)
USING DELTA;

-- Shared customers table
CREATE TABLE IF NOT EXISTS saas_platform.shared.customers (
    customer_id STRING,
    tenant_id STRING,
    full_name STRING,
    email STRING,
    phone STRING,
    ssn STRING,
    tier STRING,
    region STRING,
    created_at TIMESTAMP
)
USING DELTA;

Insert sample data for multiple tenants:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- Acme Corp data
INSERT INTO saas_platform.shared.orders VALUES
    ('ORD-001', 'acme', 'AC-100', '[email protected]',
     '+1-555-0101', 'Widget Pro', 299.99, '2026-04-01', 'US-WEST'),
    ('ORD-002', 'acme', 'AC-101', '[email protected]',
     '+1-555-0102', 'Widget Basic', 99.99, '2026-04-02', 'US-EAST'),
    ('ORD-003', 'acme', 'AC-102', '[email protected]',
     '+1-555-0103', 'Widget Enterprise', 999.99, '2026-04-03', 'EU-WEST');

-- Globex data
INSERT INTO saas_platform.shared.orders VALUES
    ('ORD-004', 'globex', 'GX-200', '[email protected]',
     '+44-20-7946-0958', 'Gadget X', 450.00, '2026-04-01', 'EU-WEST'),
    ('ORD-005', 'globex', 'GX-201', '[email protected]',
     '+44-20-7946-0959', 'Gadget Y', 750.00, '2026-04-02', 'APAC');

-- Initech data
INSERT INTO saas_platform.shared.orders VALUES
    ('ORD-006', 'initech', 'IN-300', '[email protected]',
     '+1-555-0201', 'Tool Alpha', 150.00, '2026-04-01', 'US-WEST'),
    ('ORD-007', 'initech', 'IN-301', '[email protected]',
     '+1-555-0202', 'Tool Beta', 350.00, '2026-04-03', 'US-EAST');

-- Customer data
INSERT INTO saas_platform.shared.customers VALUES
    ('AC-100', 'acme', 'Alice Johnson', '[email protected]',
     '+1-555-0101', '123-45-6789', 'gold', 'US-WEST',
     current_timestamp()),
    ('GX-200', 'globex', 'Dave Chen', '[email protected]',
     '+44-20-7946-0958', '987-65-4321', 'platinum', 'EU-WEST',
     current_timestamp()),
    ('IN-300', 'initech', 'Frank Miller', '[email protected]',
     '+1-555-0201', '456-78-9012', 'silver', 'US-WEST',
     current_timestamp());

Step 2: Create Groups for Tenant Mapping
#

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.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from databricks.sdk import WorkspaceClient

w = WorkspaceClient()

# Create tenant groups
for tenant in ["tenant_acme", "tenant_globex", "tenant_initech"]:
    w.groups.create(display_name=tenant)

# Create role groups
for role in ["platform_admins", "data_ops"]:
    w.groups.create(display_name=role)

# Add users to groups (replace with your actual usernames)
def add_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}"'))
    if groups and users:
        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]")

Step 3: Row Filters for Tenant Isolation
#

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
CREATE OR REPLACE FUNCTION
    saas_platform.security.tenant_filter(tenant_id STRING)
RETURNS BOOLEAN
RETURN
    -- 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
ALTER TABLE saas_platform.shared.orders
SET ROW FILTER saas_platform.security.tenant_filter
ON (tenant_id);

-- Apply row filter to customers
ALTER TABLE saas_platform.shared.customers
SET ROW FILTER saas_platform.security.tenant_filter
ON (tenant_id);

Now test it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- As an acme_analyst user:
SELECT current_user(), count(*) FROM saas_platform.shared.orders;
-- Returns: [email protected], 3

-- As a globex_analyst user:
SELECT current_user(), count(*) FROM saas_platform.shared.orders;
-- Returns: [email protected], 2

-- As a platform_admin user:
SELECT current_user(), count(*) FROM saas_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.


Step 4: Column Masks for PII Protection
#

Column masks transform column values based on the caller’s role. The data_ops group sees raw values. Everyone else sees masked versions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Mask email: show first char + domain, hash the rest
CREATE OR REPLACE FUNCTION
    saas_platform.security.mask_email(email STRING)
RETURNS STRING
RETURN
    CASE
        WHEN is_account_group_member('data_ops') THEN email
        ELSE regexp_replace(email, '(.).*@', '\\1***@')
    END;

-- Mask phone: show last 4 digits
CREATE OR REPLACE FUNCTION
    saas_platform.security.mask_phone(phone STRING)
RETURNS STRING
RETURN
    CASE
        WHEN is_account_group_member('data_ops') THEN phone
        ELSE concat('***-', right(phone, 4))
    END;

-- Mask SSN: always mask for non-data_ops, show only last 4
CREATE OR REPLACE FUNCTION
    saas_platform.security.mask_ssn(ssn STRING)
RETURNS STRING
RETURN
    CASE
        WHEN is_account_group_member('data_ops') THEN ssn
        ELSE concat('***-**-', right(ssn, 4))
    END;

Apply masks to columns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
-- Orders table
ALTER TABLE saas_platform.shared.orders
ALTER COLUMN customer_email
SET MASK saas_platform.security.mask_email;

ALTER TABLE saas_platform.shared.orders
ALTER COLUMN customer_phone
SET MASK saas_platform.security.mask_phone;

-- Customers table
ALTER TABLE saas_platform.shared.customers
ALTER COLUMN email SET MASK saas_platform.security.mask_email;

ALTER TABLE saas_platform.shared.customers
ALTER COLUMN phone SET MASK saas_platform.security.mask_phone;

ALTER TABLE saas_platform.shared.customers
ALTER COLUMN ssn SET MASK saas_platform.security.mask_ssn;

Test the masks:

1
2
3
-- As a regular tenant user (not in data_ops):
SELECT customer_id, email, phone, ssn
FROM saas_platform.shared.customers;
1
2
customer_id  email              phone         ssn
AC-100       a***@acme.com      ***-0101      ***-**-6789
1
2
3
-- As a data_ops user:
SELECT customer_id, email, phone, ssn
FROM saas_platform.shared.customers;
1
2
customer_id  email              phone            ssn
AC-100       [email protected]     +1-555-0101      123-45-6789

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
ALTER TABLE saas_platform.shared.customers
ALTER COLUMN email SET TAGS ('sensitivity' = 'pii');

ALTER TABLE saas_platform.shared.customers
ALTER COLUMN phone SET TAGS ('sensitivity' = 'pii');

ALTER TABLE saas_platform.shared.customers
ALTER COLUMN ssn SET TAGS ('sensitivity' = 'highly_restricted');

ALTER TABLE saas_platform.shared.customers
ALTER COLUMN tier SET TAGS ('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
SELECT
    table_catalog,
    table_schema,
    table_name,
    column_name,
    tag_name,
    tag_value
FROM system.information_schema.column_tags
WHERE tag_name = 'sensitivity'
    AND tag_value IN ('pii', 'highly_restricted')
ORDER BY table_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
SELECT
    ct.table_name,
    ct.column_name,
    ct.tag_value AS sensitivity_level,
    CASE
        WHEN cm.function_name IS NOT NULL THEN 'MASKED'
        ELSE 'UNMASKED'
    END AS mask_status
FROM system.information_schema.column_tags ct
LEFT JOIN system.information_schema.column_masks cm
    ON ct.table_catalog = cm.table_catalog
    AND ct.table_schema = cm.table_schema
    AND ct.table_name = cm.table_name
    AND ct.column_name = cm.column_name
WHERE ct.tag_name = 'sensitivity'
    AND ct.tag_value IN ('pii', 'highly_restricted')
ORDER BY mask_status, ct.table_name

Any row in this query where mask_status = 'UNMASKED' is a compliance gap that needs to be fixed.


Step 6: Prove the Isolation Works
#

Security that isn’t tested isn’t security. Here’s how to verify tenant isolation end-to-end.

Cross-Tenant Query Test
#

1
2
3
4
5
-- Execute as acme user: try to see globex data
-- (This should return 0 rows, not an error)
SELECT * FROM saas_platform.shared.orders
WHERE tenant_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.

Aggregation Leak Test
#

1
2
3
-- Can a tenant infer how many other tenants exist?
SELECT count(DISTINCT tenant_id) FROM saas_platform.shared.orders;
-- Returns: 1 (only their own tenant_id is visible)

Filter Pushdown Verification
#

Row filters should be pushed down to the scan level for performance. Verify with EXPLAIN:

1
2
3
EXPLAIN EXTENDED
SELECT * FROM saas_platform.shared.orders
WHERE region = 'US-WEST';

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.

Audit Trail
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- Who accessed what, and did the filter apply?
SELECT
    user_identity.email,
    request_params.commandText,
    event_time,
    response.status_code
FROM system.access.audit
WHERE action_name = 'commandSubmit'
    AND request_params.commandText LIKE '%shared.orders%'
ORDER BY event_time DESC
LIMIT 50
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- Aggregate: per-tenant query patterns
SELECT
    user_identity.email,
    DATE(event_time) AS query_date,
    COUNT(*) AS query_count
FROM system.access.audit
WHERE action_name = 'commandSubmit'
    AND request_params.commandText LIKE '%saas_platform.shared%'
    AND event_time >= current_date() - 30
GROUP BY user_identity.email, DATE(event_time)
ORDER BY query_date DESC, query_count DESC

Scaling to Hundreds of Tenants
#

Automated Group Provisioning
#

With SCIM sync from your IdP, tenant groups are created automatically when you onboard a new tenant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Example: onboard a new tenant programmatically
from databricks.sdk import WorkspaceClient

w = WorkspaceClient()

def onboard_tenant(tenant_id: str, admin_email: str):
    """Create tenant group and add initial admin user."""
    group_name = f"tenant_{tenant_id}"

    # Create group
    group = w.groups.create(display_name=group_name)

    # Add user to group
    user = w.users.list(filter=f'userName eq "{admin_email}"')
    user_list = list(user)
    if user_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}")
    return group

onboard_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.

Performance at Scale
#

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
ALTER TABLE saas_platform.shared.orders
SET TBLPROPERTIES ('delta.autoOptimize.optimizeWrite' = 'true');

OPTIMIZE saas_platform.shared.orders ZORDER BY (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.


Gotchas
#

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.


Conclusion
#

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.

Mariusz Derela
Author
Mariusz Derela
Cyber Security Specialist | DevSecOps | AI/ML
Databricks Challenge - This article is part of a series.
Part : This Article

Related

Delta Sharing: Zero-Copy Data Exchange Across Workspaces and Beyond
Unity Catalog Deep Dive: Fine-Grained Access Control
Mosaic AI Gateway: Unified AI Governance at Scale