Unity Catalog grants that actually work
The symptom: you granted SELECT on a table and the analyst still gets permission denied; or the opposite, a contractor can read everything because someone granted at the catalog level in a hurry. The cause is that Unity Catalog permissions are three levels deep and most grants are made at the wrong one.
The model in one paragraph
Objects live in a hierarchy: metastore, catalog, schema, table. To read a table, a principal needs USE CATALOG on the catalog, USE SCHEMA on the schema, and SELECT on the table. Granting SELECT alone does nothing visible, because the principal cannot traverse to the table. That single fact explains most "but I granted it" tickets. Privileges granted on a parent are inherited by its children, which is convenient and also how the contractor ended up reading everything.
The fix: grant to groups, at the lowest useful level
1. Create groups at the account level, not in the workspace
Account-level groups (analysts, data_eng, finance_readers) are the unit of access. Never grant to an individual user; when they leave, the grant stays behind. Sync the groups from your identity provider so membership is managed where HR already manages it.
2. Grant traversal plus the one thing the group needs
-- Analysts: read gold, nothing else
GRANT USE CATALOG ON CATALOG taxi TO `analysts`;
GRANT USE SCHEMA ON SCHEMA taxi.gold TO `analysts`;
GRANT SELECT ON SCHEMA taxi.gold TO `analysts`; -- inherited by every table in gold, present and future
-- Engineers: read silver and the quarantine table too
GRANT USE SCHEMA ON SCHEMA taxi.silver TO `data_eng`;
GRANT SELECT ON SCHEMA taxi.silver TO `data_eng`;
SELECT ON SCHEMA is the right grain for a reporting layer: new gold tables are readable the day they appear, and nothing outside gold is. Granting SELECT ON CATALOG would also expose bronze and silver, which is the contractor problem.
3. Writes belong to service principals
No human group gets MODIFY or CREATE TABLE on production schemas. Pipelines run as a service principal that owns those privileges, and the audit log then shows which job changed a table, not which person happened to be logged in. People who need to experiment get their own sandbox schema.
GRANT USE SCHEMA, CREATE TABLE, MODIFY ON SCHEMA taxi.silver TO `sp-pipelines`;
GRANT USE SCHEMA, CREATE TABLE, MODIFY ON SCHEMA taxi.gold TO `sp-pipelines`;
4. Filter rows and mask columns in the catalog, not in the report
If the finance group may only see its region, put that rule on the table so every tool inherits it. A row filter is a SQL function attached to the table; a column mask is the same idea for a single column.
CREATE FUNCTION taxi.gold.region_filter(region STRING)
RETURN is_account_group_member('all_regions') OR region = current_user_region();
ALTER TABLE taxi.gold.revenue_by_region SET ROW FILTER taxi.gold.region_filter ON (region);
(current_user_region() stands for whatever lookup you use, usually a small mapping table from user to region.) Power BI, a notebook and a SQL warehouse now all return the same filtered rows for the same person, and the rule lives in exactly one place.
Diagnosing permission denied
Work down the hierarchy. The error message names the privilege and the object; check that one, then its parent.
SHOW GRANTS ON CATALOG taxi;
SHOW GRANTS ON SCHEMA taxi.gold;
SHOW GRANTS ON TABLE taxi.gold.trips_by_zone_hour;
SHOW GRANTS `analysts`; -- everything this group holds, anywhere
The three common causes, in order: missing USE SCHEMA (the grant was made on the table only); the user is not actually in the group they think they are in (check account groups, not workspace groups); the object is owned by a user who has since left and the owner's privileges were never transferred (ALTER TABLE ... OWNER TO fixes it).
Proving who read what
The audit log is a system table, so "who accessed the revenue table last month" is a query rather than a support ticket.
SELECT event_time, user_identity.email, action_name, request_params.full_name_arg AS object
FROM system.access.audit
WHERE request_params.full_name_arg LIKE 'taxi.gold.%'
AND event_date >= current_date() - INTERVAL 30 DAYS
ORDER BY event_time DESC;
Pair it with system.access.table_lineage to see which reports depend on a table before you change it.
Two habits that keep it clean
- A grants file in the repository. All grants live in one SQL script that is applied by a job, so the current state is readable and reviewable, and a grant made by hand in the UI stands out as the exception.
- A quarterly read of
SHOW GRANTSper group. Takes ten minutes, finds the catalog-level grant someone made during an incident.
When to ask for help
If the workspace predates Unity Catalog and tables still live in the legacy Hive metastore, the migration is the real job and the grants come with it. If the catalog exists but nobody can say who can read what, a one-day audit produces the grants file and the list of surprises.
Related: who can read what in the lakehouse FAQ, Unity Catalog and service principal in the glossary.
Cannot tell who can read the revenue table?
Send the output of SHOW GRANTS on the schema, names removed. We reply within a working day with what is wrong and the script that fixes it.