Check every sensitive table in Snowflake, Databricks and Redshift has a current owner
The short answer
Export each table's owner, your sensitive-table list, each group's members and your HR file, and list what each owner is. Run the script. The script lists every sensitive table whose owner is missing, has left, or is a group or role nobody current is in. A person decides what to do with each finding.
What you will have at the end
A Python script, sensitive_table_owners.py, that reads five CSV files and prints, for each platform and data area, every sensitive table with a finding. It also writes owner_evidence.csv, one row per sensitive table. The findings are:
NO_OWNER: no owner is recorded, or the owner is not listed in your principals file, or the listed person has no HR email or HR record or was not employed on the as-of day.OWNER_LEFT: the owner is a person whose HR end date is on or before the as-of day. The line gives the days since.GROUP_NOBODY_CURRENT: the owner is a role or group, and none of its members still works here, or it has no members. Roles and groups inside it are followed.SERVICE_ACCOUNT_NO_PERSON: the owner is a service account or service principal with no named person, or whose named person has left, has no HR record or was not employed on the as-of day.
The script reads CSV files and writes only its own output files. It never connects to a platform and never changes one. It does not decide who should own a table. It shows which sensitive tables have an owner who is a current person, a group or role with a current person in it, or a service account with a current person named. A person decides what to do with each finding. The same steps with more detail are in the full guide: get it by email.
Three of the five files come from your platforms: owners, sensitive tables (from tags, on Snowflake and Databricks) and members. You supply the other two, the principals file and the HR file. The first four runs below use generated records: invented people with example.com addresses, laid out in the columns each platform documents. They are not a company's data and not a run on Snowflake, Databricks or Redshift. Run 5 takes the owner, tag and group-member exports from real Snowflake and Databricks sandbox accounts. Its HR file and its two Redshift tables are generated.
Step 1. Export who owns each table
Each platform records an owner for every table, and what kind of name it is differs. In Snowflake the owner is a role: TABLES describes TABLE_OWNER as the “Name of the role that owns the table” (read 2026-11-05), so the person behind it is whoever holds the role. In Databricks, table_owner is the “User or group (principal) currently owning the relation” (information_schema.tables (read 2026-11-05)), and the ownership page says an owner can be a user, a service principal (by its application id) or a group (read 2026-11-05). In Redshift, SVV_REDSHIFT_TABLES has table_owner, “The owner of the table” (read 2026-11-05).
These are the queries a reader runs. Each writes the columns owners.csv needs. The Snowflake form reads ACCOUNT_USAGE, which the documentation says can lag by up to 90 minutes for TABLES (read 2026-11-05); the pages for TAG_REFERENCES and GRANTS_TO_USERS, linked in Steps 2 and 3, say up to 120 minutes (read 2026-11-05). We could not run the ACCOUNT_USAGE queries or the Redshift queries in our sandboxes: our Snowflake role cannot read ACCOUNT_USAGE (the error is in Run 5) and we had no Redshift account. They follow the linked documentation and are untested. For owners, tags and members, Run 5 used the INFORMATION_SCHEMA TABLES view from the first link, which has the same four columns, the INFORMATION_SCHEMA.TAG_REFERENCES function (read 2026-11-05) and SHOW GRANTS OF ROLE, all shown in its output below.
-- Snowflake -> owners.csv
SELECT 'snowflake' AS platform, table_catalog, table_schema, table_name, table_owner
FROM SNOWFLAKE.ACCOUNT_USAGE.TABLES
WHERE deleted IS NULL; -- views and external tables too: a tag on a view makes it sensitive as well
-- Databricks -> owners.csv (once for each catalog)
SELECT 'databricks' AS platform, table_catalog, table_schema, table_name, table_owner
FROM <catalog>.information_schema.tables;
-- Redshift -> owners.csv (as a superuser: the documentation says other users see only their own rows)
SELECT 'redshift' AS platform, database_name AS table_catalog, schema_name AS table_schema,
table_name, table_owner
FROM svv_redshift_tables;
Step 2. List the sensitive tables, with their data area
Snowflake and Databricks can carry the answer as a tag. In this guide the tag is called SENSITIVE_DATA_AREA and its value is the data area, such as finance. Snowflake's ACCOUNT_USAGE.TAG_REFERENCES lists tag assignments, but “Tag inheritance is not included in this view” (read 2026-11-05). A table whose SENSITIVE_DATA_AREA is set on its schema or its database has no row of its own in ACCOUNT_USAGE.TAG_REFERENCES, so the Snowflake query below gives each table its own tag, or else its schema’s, or else its database’s. The INFORMATION_SCHEMA.TAG_REFERENCES function Run 5 uses also returns the tags a table inherits from its schema or database, with APPLY_METHOD set to INHERITED. Databricks has information_schema.table_tags (read 2026-11-05). Redshift has no table tags in the pages we read, so for Redshift, and for any table you track some other way, you write the rows into sensitive.csv yourself. Use your own tag name if you already have one. We did not run the ACCOUNT_USAGE queries below; they follow the linked documentation and are untested. Run 5 ran the INFORMATION_SCHEMA forms, with its own output.
-- Snowflake -> sensitive.csv (the table's own tag, else its schema's, else its database's)
WITH tags AS (
SELECT domain, object_database, object_schema, object_name, tag_value
FROM SNOWFLAKE.ACCOUNT_USAGE.TAG_REFERENCES
WHERE tag_name = 'SENSITIVE_DATA_AREA' AND object_deleted IS NULL
AND domain IN ('TABLE', 'SCHEMA', 'DATABASE'))
SELECT 'snowflake' AS platform, t.table_catalog, t.table_schema, t.table_name,
COALESCE(tt.tag_value, ts.tag_value, td.tag_value) AS data_area
FROM SNOWFLAKE.ACCOUNT_USAGE.TABLES t
LEFT JOIN tags tt ON tt.domain = 'TABLE' AND tt.object_database = t.table_catalog
AND tt.object_schema = t.table_schema AND tt.object_name = t.table_name
LEFT JOIN tags ts ON ts.domain = 'SCHEMA' AND ts.object_database = t.table_catalog
AND ts.object_name = t.table_schema
LEFT JOIN tags td ON td.domain = 'DATABASE' AND td.object_name = t.table_catalog
WHERE t.deleted IS NULL AND COALESCE(tt.tag_value, ts.tag_value, td.tag_value) IS NOT NULL;
-- Databricks -> sensitive.csv (once for each catalog)
SELECT 'databricks' AS platform, catalog_name AS table_catalog, schema_name AS table_schema,
table_name, tag_value AS data_area
FROM <catalog>.information_schema.table_tags
WHERE tag_name = 'sensitive_data_area';
Step 3. Say who each owner name is, and who is in each group
principals.csv is a list you write. For each platform it names every owner and member that can appear: its kind (person, group, role or service_account), and for a person the work email your HR file holds, or for a service account the email of the person responsible. An owner the file does not list is reported as NO_OWNER, because nobody has said who it is.
members.csv lists who is in each group or role. Snowflake's GRANTS_TO_USERS has ROLE, GRANTEE_NAME and DELETED_ON (read 2026-11-05). A role granted to another role comes from SHOW GRANTS OF ROLE, which returns granted_to (ROLE or USER) and grantee_name (read 2026-11-05). For roles that own objects, Snowflake “recommends creating a hierarchy of custom roles, with the top-most custom role assigned to the system role SYSADMIN” (access control overview (read 2026-11-05)), so in an account that follows that advice, the chain above an owner role reaches SYSADMIN. The script follows custom roles in that chain but never counts the users of Snowflake’s system roles (ACCOUNTADMIN, ORGADMIN, SECURITYADMIN, SYSADMIN and USERADMIN, the SKIP_ROLES setting) as people behind a custom owner role. The users of those system roles are administrators, and counting those users would hide an owner role whose own users have all left. A table owned by a system role itself is still followed to the people who hold that role, for example from SYSADMIN to ACCOUNTADMIN to the users of ACCOUNTADMIN. Databricks group members come from the workspace's SCIM list groups call, whose groups carry displayName and members (read 2026-11-05). Redshift lists its groups in pg_group, and the Groups page gives the join to users (read 2026-11-05). We did not run the ACCOUNT_USAGE and Redshift queries below, and the Databricks group call returned the result shown in Run 5; the queries follow the linked documentation and are untested.
-- Snowflake -> members.csv (users in roles; add roles inside roles from SHOW GRANTS OF ROLE)
SELECT 'snowflake' AS platform, role AS group_name, grantee_name AS member
FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS
WHERE deleted_on IS NULL;
-- Redshift -> members.csv
SELECT 'redshift' AS platform, g.groname AS group_name, u.usename AS member
FROM pg_user u JOIN pg_group g ON u.usesysid = ANY (g.grolist);
The HR file, hr.csv, has three columns: email, start_date and end_date, one row per period of employment, with end_date empty while the person is still here. A person who left and came back has two rows. The script reads each period on its own: a person is current on the as-of day when one row starts on or before it and has no end date or an end date after it.
Step 4. Let the input checks run first
Before it looks at one table, the script runs its input checks: that the as-of date and the platform list are usable, that every file and column is there, that every HR date is a real date written YYYY-MM-DD, that no HR row ends before it starts, that every platform value is one you listed in PLATFORMS and every principal kind is person, group, role or service_account, that no key column is blank, that every table is in the owner export once and every sensitive table once, that every sensitive table is in the owner export, that each principal is listed once per platform, and that every group and member in members.csv is listed in principals.csv. If any fails, the run says INCOMPLETE and computes nothing, because a list built on files it could not read would look clean for the wrong reason. Runs 3 and 4 show this.
Step 5. Read the findings
For each sensitive table the script looks up the owner in principals.csv, follows a role or group down to the people in it (through groups inside groups, at most ten levels, so a loop ends), and compares each person with the HR file. This is the query that turns an owner into a finding, shortened to three of its branches, each as it is in the download:
SELECT no, 'NO_OWNER' AS code, 'no owner recorded' AS detail FROM b2 WHERE owner IS NULL
UNION ALL SELECT no, 'NO_OWNER', 'owner ' || owner || ' is not listed in principals.csv'
FROM b2 WHERE owner IS NOT NULL AND kind IS NULL
...
UNION ALL SELECT no, 'OWNER_LEFT', 'person ' || owner || ' left ' || strftime(p_left, '%Y-%m-%d') || ', '
|| date_diff('day', p_left, {asof}) || ' days before {AS_OF}'
FROM b2 WHERE kind = 'person' AND p_hr AND NOT p_cur AND p_left IS NOT NULL
In the download {asof} and {AS_OF} are filled in from your AS_OF setting.
| Finding | What it says | What a person does with it |
|---|---|---|
NO_OWNER | No owner recorded, an owner nobody listed, or a listed person with no HR email or HR record, or who was not employed on the as-of day. | Name an owner for the table, or list the owner in principals.csv if it is a real one. |
OWNER_LEFT | The owner is a person who left on the date shown. | Hand the table to a current owner on the platform, then run the check again. |
GROUP_NOBODY_CURRENT | The owning role or group has no member who still works here. | Add a current person to the role or group, or give the table to a role or group that has one. |
SERVICE_ACCOUNT_NO_PERSON | A service account owns the table and no current person is named for it. | Name the person responsible for the service account in principals.csv. |
Run 1: six tables with a finding, planted
We ran the script on 2026-11-05 with DuckDB 1.5.6. --plant-error changes six of the 14 generated sensitive tables so that each finding has something to find: a Databricks table with no owner, a Databricks table whose owner is listed nowhere, a Redshift table whose owner left, a Snowflake table owned by a role whose only user left (the role is also granted to SYSADMIN, whose user still works here, and the script still flags the table), a Databricks table owned by a group with no members, and a Databricks table owned by a service principal with no named person. The script stops before planting if a planted name already occurs in the clean records, or if a group it empties does not have the two members it expects.
python3 guides/check-sensitive-table-owners-still-work-here/sensitive_table_owners.py --demo --plant-error --out loop-out/out
Output from our run 1
Generated records written to loop-out/out/generated-planted-error/ (not a company's data).
Run at 2026-11-05 18:41:25 UTC. Exports taken 2026-11-05.
Input checks
[ok] AS_OF is a real date written YYYY-MM-DD
[ok] PLATFORMS lists the platforms you exported, from snowflake, databricks, redshift
[ok] every file is there with every column the script reads
read 17 rows from owners.csv
read 14 rows from sensitive.csv
read 47 rows from principals.csv
read 16 rows from members.csv
read 13 rows from hr.csv
[ok] every HR start and end date is a real date written YYYY-MM-DD (a start date is required)
[ok] no HR row ends before it starts
[ok] every platform value is one of PLATFORMS
[ok] every principal kind is person, group, role or service_account
[ok] no key column is blank (platform, catalog, schema, table, data area, principal, kind, group, member)
[ok] every table is in the owner export once
[ok] every sensitive table is listed once
[ok] every sensitive table is in the owner export
[ok] each principal is listed once per platform (names compared without regard to case)
[ok] every membership group is a group or role listed in principals
[ok] every member is listed in principals
Population: 14 sensitive tables, owners as of 2026-11-05, run 2026-11-05 18:41:25 UTC, SHA-256 0a4d0bb44bc2320d...
Platforms
snowflake checked: 6 table owners read
databricks checked: 6 table owners read
redshift checked: 5 table owners read
Findings (one line per gap; a table can have more than one)
databricks customer main.marketing.consents SERVICE_ACCOUNT_NO_PERSON service account sp-etl-nightly has no named person
databricks customer main.marketing.leads NO_OWNER owner contractor-tmp is not listed in principals.csv
databricks finance main.finance.gl_balances NO_OWNER no owner recorded
databricks hr main.hr.performance_reviews GROUP_NOBODY_CURRENT group data-stewards has no members
redshift hr dev.hr.salaries OWNER_LEFT person hugo left 2026-08-05, 92 days before 2026-11-05
snowflake customer PROD_DB.CUSTOMER.ACCOUNTS GROUP_NOBODY_CURRENT role LEGACY_OWNER: none of its 1 member(s) works here (latest left 2026-09-18)
Counts by finding
NO_OWNER 2
OWNER_LEFT 1
GROUP_NOBODY_CURRENT 2
SERVICE_ACCOUNT_NO_PERSON 1
By platform and data area
platform data area tables with a finding
databricks customer 2 2
databricks finance 2 1
databricks hr 1 1
redshift customer 1 0
redshift finance 1 0
redshift hr 2 1
snowflake customer 2 1
snowflake finance 2 0
snowflake hr 1 0
14 sensitive tables read: 8 with an owner who still works here, a group or role with someone who does, or a service account with a named person who does; 6 with at least one finding.
Evidence for every sensitive table written to loop-out/out/owner_evidence-planted-error.csv (14 rows).
Six of 14 sensitive tables are flagged, one finding each. The other eight each get a row in owner_evidence.csv with the verdict OK.
Run 2: the clean records
The same script on the same records without --plant-error. No finding is printed. The clean records include a role with one member who left and two who did not, a role inside a role, a table owned by SYSADMIN, a role held by one user through ACCOUNTADMIN, a person who left in 2024 and was hired back in 2025, and three tables that nobody listed as sensitive, owned by a person who left or by a role that person holds. None of these is a finding. The fingerprint line names the population: the same data gives the same SHA-256 on every run.
python3 guides/check-sensitive-table-owners-still-work-here/sensitive_table_owners.py --demo --out loop-out/out
Output from our run 2 (from the population on)
Population: 14 sensitive tables, owners as of 2026-11-05, run 2026-11-05 18:41:25 UTC, SHA-256 f2eb349533cca5a6... Platforms snowflake checked: 6 table owners read databricks checked: 6 table owners read redshift checked: 5 table owners read Findings (one line per gap; a table can have more than one) none Counts by finding NO_OWNER 0 OWNER_LEFT 0 GROUP_NOBODY_CURRENT 0 SERVICE_ACCOUNT_NO_PERSON 0 By platform and data area platform data area tables with a finding databricks customer 2 0 databricks finance 2 0 databricks hr 1 0 redshift customer 1 0 redshift finance 1 0 redshift hr 2 0 snowflake customer 2 0 snowflake finance 2 0 snowflake hr 1 0 14 sensitive tables read: 14 with an owner who still works here, a group or role with someone who does, or a service account with a named person who does; 0 with at least one finding. Evidence for every sensitive table written to loop-out/out/owner_evidence.csv (14 rows).
Run 3: the input checks stop a bad export
--plant-input plants eleven faults in the generated files, one for each of eleven input checks: a start date written 01/10/26, an HR row that ends before it starts, an owner row on a platform the script does not know (oracle), a principal of kind team, a sensitive table with no data area, an owner row repeated, a sensitive row repeated, a sensitive table that is not in the owner export, a principal listed twice in different case, a group in members.csv that no principal lists, and a member that no principal lists. All eleven checks fail and the run stops with no findings.
python3 guides/check-sensitive-table-owners-still-work-here/sensitive_table_owners.py --demo --plant-input --out loop-out/out
Output from our run 3 (from the input checks on)
Input checks
[ok] AS_OF is a real date written YYYY-MM-DD
[ok] PLATFORMS lists the platforms you exported, from snowflake, databricks, redshift
[ok] every file is there with every column the script reads
read 20 rows from owners.csv
read 17 rows from sensitive.csv
read 48 rows from principals.csv
read 18 rows from members.csv
read 14 rows from hr.csv
[FAIL] every HR start and end date is a real date written YYYY-MM-DD (a start date is required): 1 HR row(s) with a missing start date or a date not written YYYY-MM-DD
[FAIL] no HR row ends before it starts: 1 HR row(s) with an end date before the start date
[FAIL] every platform value is one of PLATFORMS: unlisted platforms: owners.csv: 1
[FAIL] every principal kind is person, group, role or service_account: 1 principal row(s) with another kind
[FAIL] no key column is blank (platform, catalog, schema, table, data area, principal, kind, group, member): 1 row(s) with a blank key column
[FAIL] every table is in the owner export once: 1 owner row(s) repeat a table
[FAIL] every sensitive table is listed once: 1 sensitive row(s) repeat a table
[FAIL] every sensitive table is in the owner export: 1 sensitive table(s) have no row in owners.csv: a stale list, or the export cannot see them
[FAIL] each principal is listed once per platform (names compared without regard to case): 1 principal row(s) repeat a name
[FAIL] every membership group is a group or role listed in principals: 1 membership row(s) name a group that principals.csv does not list as a group or role
[FAIL] every member is listed in principals: 1 membership row(s) name a member that principals.csv does not list
INCOMPLETE: 11 input check(s) failed, so no findings are computed. Fix the input.
Run 4: a renamed column stops the run
--plant-header renames the member column of members.csv to login. That is a run of its own, because with the column missing the file reads as empty and the two membership checks would have nothing to test. The file-and-column check fails.
python3 guides/check-sensitive-table-owners-still-work-here/sensitive_table_owners.py --demo --plant-header --out loop-out/out
Output from our run 4 (from the input checks on)
Input checks
[ok] AS_OF is a real date written YYYY-MM-DD
[ok] PLATFORMS lists the platforms you exported, from snowflake, databricks, redshift
[FAIL] every file is there with every column the script reads: members.csv has no column member
read 17 rows from owners.csv
read 14 rows from sensitive.csv
read 46 rows from principals.csv
read 0 rows from members.csv
read 13 rows from hr.csv
[ok] every HR start and end date is a real date written YYYY-MM-DD (a start date is required)
[ok] no HR row ends before it starts
[ok] every platform value is one of PLATFORMS
[ok] every principal kind is person, group, role or service_account
[ok] no key column is blank (platform, catalog, schema, table, data area, principal, kind, group, member)
[ok] every table is in the owner export once
[ok] every sensitive table is listed once
[ok] every sensitive table is in the owner export
[ok] each principal is listed once per platform (names compared without regard to case)
[ok] every membership group is a group or role listed in principals
[ok] every member is listed in principals
INCOMPLETE: 1 input check(s) failed, so no findings are computed. Fix the input.
Run 5: real exports from a Snowflake and a Databricks account
On 2026-11-05 we created four tables in a Snowflake account and four in a Databricks workspace: in each, three sensitive tables, tagged with SENSITIVE_DATA_AREA, and one that is not. We ran the export queries and saved the results as the CSV files the script reads. The setup statements ran in the same session as the queries; both schemas were dropped afterwards. Four things in what came back matter:
- In our Snowflake sandbox, one role owned every table. The owner column returned the role
CONTENT_LOOP_ROLEfor all four tables, andSHOW GRANTS OF ROLEshowed one member, the userCONTENT_LOOP_RUNNER. - The
ACCOUNT_USAGEform was refused. The role could not readSNOWFLAKE.ACCOUNT_USAGE; the error is below word for word. A reader whose role can read it needs the Step 1 to 3 queries; we have not run them. - In our Databricks workspace, the owner column returned an id (a UUID) for the three tables we created and left alone, which the ownership page says is how a service principal is written (its application id), and the group
account usersfor the one we handed over withALTER TABLE ... OWNER TO(read 2026-11-05). - The workspace's group list did not include
account users. It returned two groups,adminsandusers, with no members listed. Somembers.csvholds no members foraccount usersand the script says so.
> SELECT 'snowflake' AS platform, table_catalog, table_schema, table_name, table_owner FROM CONTENT_LOOP_SANDBOX.INFORMATION_SCHEMA.TABLES WHERE table_schema = 'CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE' AND table_type = 'BASE TABLE' ORDER BY table_name PLATFORM | TABLE_CATALOG | TABLE_SCHEMA | TABLE_NAME | TABLE_OWNER ----------+----------------------+----------------------------------------------+-------------------+------------------ snowflake | CONTENT_LOOP_SANDBOX | CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE | CALENDAR | CONTENT_LOOP_ROLE snowflake | CONTENT_LOOP_SANDBOX | CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE | CUSTOMER_CONTACTS | CONTENT_LOOP_ROLE snowflake | CONTENT_LOOP_SANDBOX | CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE | EMPLOYEE_REVIEWS | CONTENT_LOOP_ROLE snowflake | CONTENT_LOOP_SANDBOX | CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE | GL_BALANCES | CONTENT_LOOP_ROLEOutput from our run 5: Snowflake tags
> SELECT 'snowflake' AS platform, object_database, object_schema, object_name, tag_value FROM TABLE(CONTENT_LOOP_SANDBOX.INFORMATION_SCHEMA.TAG_REFERENCES('CONTENT_LOOP_SANDBOX.CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE.CALENDAR', 'table')) WHERE tag_name = 'SENSITIVE_DATA_AREA'
PLATFORM | OBJECT_DATABASE | OBJECT_SCHEMA | OBJECT_NAME | TAG_VALUE
---------+-----------------+---------------+-------------+----------
> SELECT 'snowflake' AS platform, object_database, object_schema, object_name, tag_value FROM TABLE(CONTENT_LOOP_SANDBOX.INFORMATION_SCHEMA.TAG_REFERENCES('CONTENT_LOOP_SANDBOX.CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE.CUSTOMER_CONTACTS', 'table')) WHERE tag_name = 'SENSITIVE_DATA_AREA'
PLATFORM | OBJECT_DATABASE | OBJECT_SCHEMA | OBJECT_NAME | TAG_VALUE
----------+----------------------+----------------------------------------------+-------------------+----------
snowflake | CONTENT_LOOP_SANDBOX | CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE | CUSTOMER_CONTACTS | customer
> SELECT 'snowflake' AS platform, object_database, object_schema, object_name, tag_value FROM TABLE(CONTENT_LOOP_SANDBOX.INFORMATION_SCHEMA.TAG_REFERENCES('CONTENT_LOOP_SANDBOX.CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE.EMPLOYEE_REVIEWS', 'table')) WHERE tag_name = 'SENSITIVE_DATA_AREA'
PLATFORM | OBJECT_DATABASE | OBJECT_SCHEMA | OBJECT_NAME | TAG_VALUE
----------+----------------------+----------------------------------------------+------------------+----------
snowflake | CONTENT_LOOP_SANDBOX | CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE | EMPLOYEE_REVIEWS | hr
> SELECT 'snowflake' AS platform, object_database, object_schema, object_name, tag_value FROM TABLE(CONTENT_LOOP_SANDBOX.INFORMATION_SCHEMA.TAG_REFERENCES('CONTENT_LOOP_SANDBOX.CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE.GL_BALANCES', 'table')) WHERE tag_name = 'SENSITIVE_DATA_AREA'
PLATFORM | OBJECT_DATABASE | OBJECT_SCHEMA | OBJECT_NAME | TAG_VALUE
----------+----------------------+----------------------------------------------+-------------+----------
snowflake | CONTENT_LOOP_SANDBOX | CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE | GL_BALANCES | finance
Output from our run 5: the role's members
> SHOW GRANTS OF ROLE CONTENT_LOOP_ROLE created_on | role | granted_to | grantee_name | granted_by | expires_on ---------------------------------+-------------------+------------+---------------------+--------------+----------- 2026-10-15 11:13:41.551000-07:00 | CONTENT_LOOP_ROLE | USER | CONTENT_LOOP_RUNNER | ACCOUNTADMIN | NULLOutput from our run 5: the ACCOUNT_USAGE read, refused
> SELECT table_owner FROM SNOWFLAKE.ACCOUNT_USAGE.TABLES LIMIT 1 ERROR: 002003 (02000): SQL compilation error: Schema 'SNOWFLAKE.ACCOUNT_USAGE' does not exist or not authorized. Your primary role CONTENT_LOOP_ROLE or one of your secondary roles must have USAGE or any other privilege granted on SCHEMA SNOWFLAKE.ACCOUNT_USAGE.Output from our run 5: Databricks owners
> SELECT 'databricks' AS platform, table_catalog, table_schema, table_name, table_owner FROM content_loop.information_schema.tables WHERE table_schema = 'check_sensitive_table_owners_still_work_here' ORDER BY table_name platform | table_catalog | table_schema | table_name | table_owner -----------+---------------+----------------------------------------------+-------------------+------------------------------------- databricks | content_loop | check_sensitive_table_owners_still_work_here | calendar | 94d0d5fd-0163-4380-86f4-60838753527e databricks | content_loop | check_sensitive_table_owners_still_work_here | customer_contacts | 94d0d5fd-0163-4380-86f4-60838753527e databricks | content_loop | check_sensitive_table_owners_still_work_here | employee_reviews | account users databricks | content_loop | check_sensitive_table_owners_still_work_here | gl_balances | 94d0d5fd-0163-4380-86f4-60838753527eOutput from our run 5: Databricks tags and group list
> SELECT 'databricks' AS platform, catalog_name, schema_name, table_name, tag_value FROM content_loop.information_schema.table_tags WHERE schema_name = 'check_sensitive_table_owners_still_work_here' AND tag_name = 'sensitive_data_area' ORDER BY table_name platform | catalog_name | schema_name | table_name | tag_value -----------+--------------+----------------------------------------------+-------------------+---------- databricks | content_loop | check_sensitive_table_owners_still_work_here | customer_contacts | customer databricks | content_loop | check_sensitive_table_owners_still_work_here | employee_reviews | hr databricks | content_loop | check_sensitive_table_owners_still_work_here | gl_balances | finance -- Databricks: group members from the workspace's SCIM Groups list (GET /api/2.0/preview/scim/v2/Groups) > c.groups.list() displayName | members ------------+-------- admins | 0 users | 0
The sandbox holds no HR system and no Redshift cluster, so four things in the next run are generated or ours: the principals file, which says what kind each owner is (we listed the Databricks id as a service account), the HR file (the user CONTENT_LOOP_RUNNER is given the made-up work email runner.novak@example.com and a made-up leaving date of 2026-08-05), the person named for that id, and two Redshift tables, one of whose owners left on the same made-up date. Then the script ran on the files from the exports:
python3 guides/check-sensitive-table-owners-still-work-here/sensitive_table_owners.py --data loop-out/real --out loop-out/out-real
Output from our run 5, the script (from the population on)
Population: 8 sensitive tables, owners as of 2026-11-05, run 2026-11-05 18:41:26 UTC, SHA-256 a80776d7a2b9f9d6... Platforms snowflake checked: 4 table owners read databricks checked: 4 table owners read redshift checked: 2 table owners read Findings (one line per gap; a table can have more than one) databricks hr content_loop.check_sensitive_table_owners_still_work_here.employee_reviews GROUP_NOBODY_CURRENT group account users has no members redshift hr dev.hr.salaries OWNER_LEFT person hugo left 2026-08-05, 92 days before 2026-11-05 snowflake customer CONTENT_LOOP_SANDBOX.CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE.CUSTOMER_CONTACTS GROUP_NOBODY_CURRENT role CONTENT_LOOP_ROLE: none of its 1 member(s) works here (latest left 2026-08-05) snowflake finance CONTENT_LOOP_SANDBOX.CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE.GL_BALANCES GROUP_NOBODY_CURRENT role CONTENT_LOOP_ROLE: none of its 1 member(s) works here (latest left 2026-08-05) snowflake hr CONTENT_LOOP_SANDBOX.CHECK_SENSITIVE_TABLE_OWNERS_STILL_WORK_HERE.EMPLOYEE_REVIEWS GROUP_NOBODY_CURRENT role CONTENT_LOOP_ROLE: none of its 1 member(s) works here (latest left 2026-08-05) Counts by finding NO_OWNER 0 OWNER_LEFT 1 GROUP_NOBODY_CURRENT 4 SERVICE_ACCOUNT_NO_PERSON 0 By platform and data area platform data area tables with a finding databricks customer 1 0 databricks finance 1 0 databricks hr 1 1 redshift finance 1 0 redshift hr 1 1 snowflake customer 1 1 snowflake finance 1 1 snowflake hr 1 1 8 sensitive tables read: 3 with an owner who still works here, a group or role with someone who does, or a service account with a named person who does; 5 with at least one finding. Evidence for every sensitive table written to loop-out/out-real/owner_evidence.csv (8 rows).
Eight sensitive tables were read and five flagged. The three Snowflake tables are flagged because the one role that owns them holds only a user who, in the generated HR file, has left. The Databricks table owned by account users is flagged because no members came back for it. The two sensitive tables whose owner is that id pass, because in our principals file we listed the id as a service account and named a current person for it. This is how the script reports when an export holds no members for a group: it cannot tell whether the group is empty or the export could not see its members, so a person looks.
How to read the results
- A finding is a question, not a verdict. It says the owner on record does not lead to a current person. The table may still have someone who looks after it; the record that shows it is missing.
- A table without a finding has an owner on record who leads to a current person. That is not a judgment that the person is the right owner.
- Check the counts add up. The last line gives tables read and tables with a finding; the tables read must equal the rows of
sensitive.csv, and so must the rows ofowner_evidence.csv. - A platform printed as NOT CHECKED was not looked at. It had no rows in your owner file. The script says nothing about its tables, which is not the same as clean.
- Keep the file with the date of the export. The result is as of
AS_OFand of the exports you took.
Run it on your own data
To run it on your own data, save your five exports as CSV files, each with a header row, in one folder. Then change five settings at the top of the script and nothing else. Set DATA_DIR to that folder. Set AS_OF to the day you took the exports. Set PLATFORMS to the platforms you mean to cover; a platform with no rows in your owner file is reported as NOT CHECKED. Set FILE_NAMES to the names of your files. In COLUMN_MAP, change the name on the right of each line to the column name in your file; leave the names on the left as they are, and names are matched without regard to case. Write every HR date as YYYY-MM-DD. Then run the script with no options.
We ran those sentences literally on a reader's data built for the purpose, with an as-of date of 2026-11-20. It is a small invented set: other file names, other column names in another order, platform names and owners typed in capitals and in mixed case, a blank owner, a service principal with no named person, a role whose members had all left, an owner who had left, a table owned by someone who left that nobody listed as sensitive, and Redshift in PLATFORMS with no rows. Run 6 is its output. All four findings printed, Redshift was reported as NOT CHECKED, and nothing failed.
Population: 6 sensitive tables, owners as of 2026-11-20, run 2026-11-05 18:41:43 UTC, SHA-256 fef0174d7983090a... Platforms snowflake checked: 2 table owners read databricks checked: 5 table owners read redshift NOT CHECKED: no owner rows exported, so nothing is said about its tables Findings (one line per gap; a table can have more than one) databricks hr prod.hr.payroll SERVICE_ACCOUNT_NO_PERSON service account sp-batch has no named person databricks sales prod.sales.orders OWNER_LEFT person pat@corp.example.com left 2026-06-30, 143 days before 2026-11-20 databricks sales prod.sales.refunds NO_OWNER no owner recorded snowflake finance FIN_DB.LEDGER.TAX_RATES GROUP_NOBODY_CURRENT role OLD_TEAM_ROLE: none of its 1 member(s) works here (latest left 2026-03-15) Counts by finding NO_OWNER 1 OWNER_LEFT 1 GROUP_NOBODY_CURRENT 1 SERVICE_ACCOUNT_NO_PERSON 1 By platform and data area platform data area tables with a finding databricks hr 1 1 databricks sales 3 2 snowflake finance 2 1 6 sensitive tables read: 2 with an owner who still works here, a group or role with someone who does, or a service account with a named person who does; 4 with at least one finding. Evidence for every sensitive table written to owner-check-out/owner_evidence.csv (6 rows).
Download: sensitive_table_owners.py · Get the full guide (PDF)
Get the full guide as a PDF
We'll email you a link to it. Please use your work email.
Thanks, . We're sending the link to now. If it isn't there in a few minutes, check your spam or junk folder, or email info@datameaning.com.
Method
- Records in Runs 1 to 4: generated by the script with no random numbers: twelve people with
example.comaddresses, three platforms, 14 sensitive tables and three that are not, all invented. Nothing in them is a measurement of any company or platform. - Column names: taken from the platforms' pages linked in Steps 1 to 3 (read 2026-11-05).
- Who is current: a person is current on
AS_OFwhen one HR row has a start date on or before it and no end date, or an end date after it. A person is a leaver when none of their rows is current and one has an end date on or beforeAS_OF. A person whose only row starts afterAS_OFis reported as not employed on that day. - Groups: a role or group counts as having a current person if any person inside it, at any depth up to ten, is current. A service account that is a member of a group is not a person and does not count.
- Names: compared without regard to case or surrounding spaces, so Snowflake's upper-case names and Databricks' emails match the way you wrote them in
principals.csv. - Checks and what trips each, in the order of the findings:
NO_OWNER(a Databricks table with a blank owner, and one owned bycontractor-tmp),OWNER_LEFT(the Redshift table owned byhugo),GROUP_NOBODY_CURRENT(the Snowflake roleLEGACY_OWNER, and the Databricks groupdata-stewardswith its members removed),SERVICE_ACCOUNT_NO_PERSON(the service principalsp-etl-nightlywith its named person removed). Input checks: HR date readable (01/10/26), no HR row ends before it starts (a second row forhugo), platform known (oracle), kind known (team), no blank key (a sensitive table with no data area), owner table once (a repeated row), sensitive table once (a repeated row), sensitive table in the owner export (NOT_EXPORTED), principal once (finance_ownerbesideFINANCE_OWNER), membership group listed (GHOST_ROLE), member listed (ZELDA), file and column present (the renamedmemberheader in Run 4). The two settings checks (a realAS_OFdate, andPLATFORMSfrom the three known names) are tripped by anAS_OFthat is not a date or aPLATFORMSlist with another name; no run plants either. - Not a check: if the sensitive tables the script read differ from the tables it joined to an owner, it stops with an internal error. No input of ours trips it, and it is not a check on your data.
- Mapping tables written out: the known platforms, principal kinds and which kinds count as a group are lists at the top of the script; edit them if you disagree.
- Spreadsheet safety: a text cell in
owner_evidence.csvthat starts with=,+,-,@, a tab or a carriage return gets a'in front, so a spreadsheet shows it as text and does not run it as a formula.
Limits
- This is not a client result and not a measurement. Runs 1 to 4 and Run 6 use generated or invented records. Run 5 is real exports from sandbox accounts of ours, with a generated HR file. They say nothing about how often a real table has an owner who left.
- It checks who is on record, not who looks after the table. A current owner may never open the table, and a flagged table may have a person who does. A person has to judge.
- An HR file is only as good as its end dates. An owner who left but has no end date yet shows as current.
- The script matches by the names you give it. If a Snowflake user and a Databricks user are the same person with different logins, list both in
principals.csvwith the same email. - Snowflake owns tables through roles, and roles are shared. A role held by a hundred people has a current person in it and passes. Whether that is a useful owner is your policy to set.
- A group the export cannot read looks empty. The script reports a group with no members as
GROUP_NOBODY_CURRENT. Check that your export could see its members before you act on it. Run 5 shows one case. A group whose members are only service accounts, or only empty groups, is reported the same way, because only people count. - Redshift's sensitive tables are not exported by a tag. You supply the list of sensitive tables for it yourself. The documentation says a regular user sees only their own rows of
SVV_REDSHIFT_TABLES, so export it as a superuser; a sensitive table missing from the export fails an input check. - It does not change anything. It does not hand a table to a new owner, edit a group or contact anyone.
- Evidence, not a conclusion. What it prints is one input to a review. It does not say a review or a framework has been passed, and your own policy and your auditor decide what is enough.
Tested on
- Platform
- DuckDB 1.5.6, Python 3.12, run from Python with the
duckdbmodule - Edition
- Open-source DuckDB, in-memory. The Run 5 exports came from Snowflake Enterprise Edition and from Azure Databricks, Premium tier, serverless compute, Unity Catalog.
- Date
- 2026-11-05
- Dataset
- Runs 1 to 4: generated records, shaped like the three platforms' documented columns. Run 5: real owner, tag and group-member exports from Snowflake and Databricks sandbox accounts, with a generated HR file and two generated Redshift tables. Run 6: an invented reader layout with an invented as-of date of 2026-11-20, not an export from any platform. There was no Redshift account: nothing here is a run on Redshift.
- Code
- sensitive_table_owners.py
When this is not enough
- The owner on record is wrong. The script follows the owner the platform shows. Someone has to decide who should own each table and write it down.
- Your HR file is not in one place. Contractors, agencies and acquired companies often sit outside it. List them in
principals.csvand give each a row in the HR file, or accept the findings. - You need to run the script every night, not once. A single run is a picture on one day. Running it on a schedule, with the exports saved, is the next step.
- Finding is not fixing. A lasting fix is a rule in your own process: no table is created without an owner, and owners are re-checked when someone leaves.
Get evidence of who owns each sensitive table
Governance Evidence Pack is in early access, open now to design partners. For each sensitive table, it checks that the owner is a group or someone who still works here, and lists the tables without such an owner by platform and data area.
Governance Evidence Pack reads your data platforms and never writes to them. We produce the evidence. Your auditor decides.