Tag-Based Access Control (ABAC)
Tag-based access control lets you classify objects with tags (pii, region, data_class, ...) and bind policies to those tags rather than to specific columns or tables. Whenever a tagged column is queried, the engine walks the column → table → schema → catalog tag chain, dispatches each tag's bound masking policy by data type, and applies the corresponding row-access policy — without anyone touching the original CREATE TABLE or GRANT surface.
ABAC is the governance layer that complements Data Masking and RLS:
- Direct column masks are still authoritative — they override anything tag-bound.
- Tag-bound masks dispatch by data type, so one tag (
pii) can maskVARCHARcolumns one way andBIGINTcolumns another. - Tag-bound row policies attach via tag, with optional column mapping so a single policy body reuses across tables whose column names differ.
Syntax at a Glance
-- Tag lifecycle
CREATE TAG [IF NOT EXISTS] <name>
[ ALLOWED_VALUES = ('v1', 'v2', ...) ]
[ COMMENT = '...' ];
DROP TAG [IF EXISTS] <name>;
SHOW TAGS;
SHOW TAG REFERENCES [TAG_NAME = '<tag>'];
-- Bind a masking policy to a tag (one per declared input data type)
ALTER TAG <tag> SET MASKING POLICY <policy>
[, MASKING POLICY <policy2> ... ];
ALTER TAG <tag> UNSET MASKING POLICY <policy>;
-- Bind a row-access policy to a tag
ALTER TAG <tag> SET ROW ACCESS POLICY <policy>
ON TABLE <catalog.schema.table>
[ USING (<param> => <column> [, <param> => <column> ...]) ];
ALTER TAG <tag> UNSET ROW ACCESS POLICY <policy>
ON TABLE <catalog.schema.table>;
-- Attach / detach tags to / from objects
ALTER TABLE <table> SET TAG <tag> = '<value>';
ALTER TABLE <table> UNSET TAG <tag>;
ALTER TABLE <table> ALTER COLUMN <col> SET TAG <tag> = '<value>';
ALTER TABLE <table> ALTER COLUMN <col> UNSET TAG <tag>;
-- Inspect a tag value at any level
SELECT SYSTEM$GET_TAG('<tag>', '<catalog.schema.table[.column]>');
Tag Inheritance
When the masking rewriter resolves the effective mask for a column, it walks four levels in order — most specific wins:
- Column —
ALTER TABLE t ALTER COLUMN c SET TAG ... - Table —
ALTER TABLE t SET TAG ... - Schema — assignments scoped to a schema (
object_type = 'SCHEMA') - Catalog — assignments scoped to a catalog (
object_type = 'CATALOG')
The first level that yields a tag binding for the column's data type wins. Direct column masks (set via ALTER TABLE … SET MASKING POLICY) always take precedence over any tag-derived mask.
Tag Lifecycle
CREATE TAG
-- Free-form tag (any value allowed)
CREATE TAG region;
-- Constrained values
CREATE TAG pii ALLOWED_VALUES = ('public', 'internal', 'restricted');
-- Idempotent on rerun
CREATE TAG IF NOT EXISTS pii ALLOWED_VALUES = ('public', 'internal', 'restricted');
ALLOWED_VALUES is enforced at attach time — ALTER TABLE … SET TAG pii = 'foo' fails when 'foo' isn't on the list.
SHOW TAGS
tag_name | allowed_values | bound_policies | comment | created_at
----------+---------------------------------+-------------------------------------------------+---------+------------
pii | public, internal, restricted | MASKING:mask_str(STRING), MASKING:mask_int(LONG)| | 2026-04-19…
region | | ROW:eu_only | | 2026-04-19…
DROP TAG
DROP TAG cascades — it removes the tag definition, every assignment that references the tag (table-level, column-level, schema-level), and every policy binding the tag carried.
DROP TAG IF EXISTS pii;
After DROP TAG, SHOW TAG REFERENCES TAG_NAME = 'pii' returns zero rows.
Binding a Masking Policy to a Tag
-- Define the policy as usual (see "Data Masking")
CREATE OR REPLACE MASKING POLICY mask_str AS (val VARCHAR) RETURNS VARCHAR ->
CASE WHEN $value IS NULL THEN NULL ELSE '***MASKED***' END;
-- Bind to the tag
ALTER TAG pii SET MASKING POLICY mask_str;
A tag may bind one masking policy per input data type. The multi-policy syntax binds them all in a single statement:
CREATE OR REPLACE MASKING POLICY mask_str AS (v VARCHAR) RETURNS VARCHAR -> '***';
CREATE OR REPLACE MASKING POLICY mask_int AS (v BIGINT) RETURNS BIGINT -> -1;
ALTER TAG pii SET MASKING POLICY mask_str, MASKING POLICY mask_int;
The rewriter dispatches by the column's data type:
| Column Arrow type | Maps to (DataTypeName) | Matches policy declared as |
|---|---|---|
Utf8 / LargeUtf8 | STRING | (STRING) / (VARCHAR) / (TEXT) / (CHAR) |
Int8 / Int16 / Int32 | INT | (INT) / (INTEGER) |
Int64 | LONG | (BIGINT) / (LONG) / (INT64) |
Float32 | FLOAT | (FLOAT) / (REAL) |
Float64 | DOUBLE | (DOUBLE) / (FLOAT64) |
Decimal128/256 | DECIMAL | (DECIMAL) / (NUMERIC) |
Date32 / Date64 | DATE | (DATE) |
Timestamp(_, _) | TIMESTAMP | (TIMESTAMP) / (DATETIME) |
Boolean | BOOLEAN | (BOOLEAN) / (BOOL) |
Binary / LargeBinary | BINARY | (BINARY) / (BYTES) / (VARBINARY) |
CREATE TABLE AS SELECT and SELECT … VALUES (1) produce columns of type Int64 (BIGINT) for integer literals. A masking policy declared with (val INT) will not dispatch to those columns — declare the policy as (val BIGINT). The tag walk silently passes through when no type-matching binding exists, so dispatch failures look like "the mask didn't fire."
Unbinding
ALTER TAG pii UNSET MASKING POLICY mask_int; -- drops only the BIGINT binding
ALTER TAG pii UNSET MASKING POLICY mask_str; -- drops the STRING binding too
Binding a Row-Access Policy to a Tag
A row-access policy created with CREATE POLICY (see RLS) may be bound to a tag. When a table tagged with that tag is queried, the policy's USING clause is added to the scan's filter list — even if RLS isn't otherwise enabled on the table.
CREATE POLICY eu_only ON catalog.schema.customers FOR SELECT
USING (region = 'EU');
CREATE TAG eu_data;
ALTER TAG eu_data SET ROW ACCESS POLICY eu_only
ON TABLE catalog.schema.customers;
ALTER TABLE catalog.schema.customers SET TAG eu_data = 'enabled';
-- Subsequent SELECTs see only rows where region = 'EU'.
ALTER TABLE catalog.schema.customers UNSET TAG eu_data;
-- Filter no longer applies (assuming RLS isn't separately enabled).
Parameterised row policies
A row policy can be reused across tables whose column names differ by passing a USING (param => column, ...) mapping at bind time. The rewriter substitutes param references in the policy body with the mapped column at query time:
-- Policy body references abstract column `classif`
CREATE POLICY classification_policy ON catalog.schema.docs FOR SELECT
USING (classif = 'public');
-- Bind to two different tables with different concrete column names
ALTER TAG sensitivity SET ROW ACCESS POLICY classification_policy
ON TABLE catalog.schema.docs
USING (classif => doc_classification);
ALTER TAG sensitivity SET ROW ACCESS POLICY classification_policy
ON TABLE catalog.schema.invoices
USING (classif => sensitivity_label);
Both => and = are accepted as the mapping operator. An empty mapping (the default) inlines the policy body verbatim.
SYSTEM$GET_TAG_ON_CURRENT_COLUMN
Inside a masking-policy body, SYSTEM$GET_TAG_ON_CURRENT_COLUMN('<tag>') resolves to the value of <tag> on the column being masked. The substitution happens at rewrite time, so the policy body branches on per-column tag values without you needing one policy per value:
CREATE OR REPLACE MASKING POLICY classify_mask AS (val VARCHAR) RETURNS VARCHAR ->
CASE WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('pii') = 'public'
THEN $value
ELSE '***'
END;
ALTER TAG pii SET MASKING POLICY classify_mask;
ALTER TABLE customers ALTER COLUMN name SET TAG pii = 'public';
ALTER TABLE customers ALTER COLUMN ssn SET TAG pii = 'restricted';
SELECT name, ssn FROM customers LIMIT 1;
-- name is raw (public branch); ssn = '***' (else branch).
The tag lookup walks column → table → schema → catalog. Returns SQL NULL when no level supplies a value or when the tag registry isn't configured.
Direct Mask vs. Tag-Bound Mask
When both a direct column mask AND a tag-bound mask cover the same column, the direct mask wins. This lets you override a broad tag policy with a one-off column-level rule:
ALTER TABLE customers ALTER COLUMN email SET MASKING POLICY mask_email;
-- Even with `pii` tag set on email, `mask_email` is the one that fires.
Worked Example
The following example walks through the policy workflow on a table you create. Use a schema where you have the required privileges:
-- Pre-flight (safe to rerun)
DROP POLICY IF EXISTS demo_row_pol ON bb.bb.demo_salaries;
DROP TAG IF EXISTS demo_pii;
DROP TAG IF EXISTS demo_multi;
DROP TAG IF EXISTS demo_region;
DROP MASKING POLICY IF EXISTS demo_mask_str;
DROP MASKING POLICY IF EXISTS demo_mask_int;
DROP MASKING POLICY IF EXISTS demo_mask_sysget;
DROP MASKING POLICY IF EXISTS demo_mask_direct;
DROP TABLE IF EXISTS bb.bb.demo_salaries;
-- Seed (CTAS persists immediately; INSERT VALUES is staged).
CREATE TABLE bb.bb.demo_salaries AS
SELECT 'alice' AS emp, 'engineering' AS dept, 150000 AS amount, '111-22-3333' AS ssn
UNION ALL SELECT 'bob', 'finance', 120000, '222-33-4444'
UNION ALL SELECT 'carol', 'engineering', 160000, '333-44-5555'
UNION ALL SELECT 'dave', 'hr', 95000, '444-55-6666';
-- 1. Column-level tag drives masking
CREATE TAG IF NOT EXISTS demo_pii
ALLOWED_VALUES = ('public', 'internal', 'restricted');
CREATE OR REPLACE MASKING POLICY demo_mask_str AS (val VARCHAR) RETURNS VARCHAR ->
CASE WHEN $value IS NULL THEN NULL ELSE '***MASKED***' END;
ALTER TAG demo_pii SET MASKING POLICY demo_mask_str;
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp SET TAG demo_pii = 'restricted';
SELECT emp, dept FROM bb.bb.demo_salaries LIMIT 3;
-- emp = '***MASKED***'; dept passes through.
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp UNSET TAG demo_pii;
-- 2. Table-level tag inheritance
ALTER TABLE bb.bb.demo_salaries SET TAG demo_pii = 'restricted';
SELECT emp, ssn, amount FROM bb.bb.demo_salaries LIMIT 3;
-- emp + ssn masked (VARCHAR bound policy); amount untouched (no INT/LONG binding).
ALTER TABLE bb.bb.demo_salaries UNSET TAG demo_pii;
-- 3. Multi-policy per tag — note BIGINT not INT (CTAS infers Int64 literals)
CREATE OR REPLACE MASKING POLICY demo_mask_int AS (val BIGINT) RETURNS BIGINT ->
CASE WHEN $value IS NULL THEN NULL ELSE -1 END;
CREATE TAG IF NOT EXISTS demo_multi;
ALTER TAG demo_multi SET MASKING POLICY demo_mask_str, MASKING POLICY demo_mask_int;
ALTER TABLE bb.bb.demo_salaries SET TAG demo_multi = 'x';
SELECT emp, amount FROM bb.bb.demo_salaries LIMIT 3;
-- emp = '***MASKED***', amount = -1.
ALTER TABLE bb.bb.demo_salaries UNSET TAG demo_multi;
-- 4. SYSTEM$GET_TAG_ON_CURRENT_COLUMN
CREATE OR REPLACE MASKING POLICY demo_mask_sysget AS (val VARCHAR) RETURNS VARCHAR ->
CASE WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('demo_pii') = 'public'
THEN $value
ELSE '***SYSGET***'
END;
ALTER TAG demo_pii SET MASKING POLICY demo_mask_sysget;
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp SET TAG demo_pii = 'public';
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN ssn SET TAG demo_pii = 'restricted';
SELECT emp, ssn FROM bb.bb.demo_salaries LIMIT 3;
-- emp = raw (public branch); ssn = '***SYSGET***' (else branch).
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp UNSET TAG demo_pii;
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN ssn UNSET TAG demo_pii;
-- 5. Direct column mask overrides tag-bound mask
CREATE OR REPLACE MASKING POLICY demo_mask_direct AS (val VARCHAR) RETURNS VARCHAR ->
CASE WHEN $value IS NULL THEN NULL ELSE '@@@' END;
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp SET MASKING POLICY demo_mask_direct;
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp SET TAG demo_pii = 'restricted';
SELECT emp FROM bb.bb.demo_salaries LIMIT 1;
-- emp = '@@@' — direct beats tag-bound.
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp UNSET TAG demo_pii;
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN emp UNSET MASKING POLICY;
-- 6. Tag-bound row-access policy
-- DISABLE RLS so the only filter source is the tag binding — CREATE POLICY
-- on a fresh CTAS table otherwise auto-enables RLS, which would mask the
-- effect of UNSET TAG on the row count.
CREATE POLICY demo_row_pol ON bb.bb.demo_salaries FOR SELECT
USING (dept = 'engineering');
ALTER TABLE bb.bb.demo_salaries DISABLE ROW LEVEL SECURITY;
CREATE TAG IF NOT EXISTS demo_region;
ALTER TAG demo_region SET ROW ACCESS POLICY demo_row_pol
ON TABLE bb.bb.demo_salaries;
SELECT COUNT(*) FROM bb.bb.demo_salaries; -- 4 (no tag set yet).
ALTER TABLE bb.bb.demo_salaries SET TAG demo_region = 'eu';
SELECT emp, dept FROM bb.bb.demo_salaries; -- 2 (engineering only).
ALTER TABLE bb.bb.demo_salaries UNSET TAG demo_region;
SELECT COUNT(*) FROM bb.bb.demo_salaries; -- 4.
-- 7. DROP TAG cascade
ALTER TABLE bb.bb.demo_salaries SET TAG demo_pii = 'restricted';
ALTER TABLE bb.bb.demo_salaries ALTER COLUMN dept SET TAG demo_pii = 'internal';
SHOW TAG REFERENCES TAG_NAME = 'demo_pii'; -- 2 rows.
DROP TAG demo_pii;
SHOW TAG REFERENCES TAG_NAME = 'demo_pii'; -- 0 rows.
-- Cleanup
DROP TAG IF EXISTS demo_multi;
DROP TAG IF EXISTS demo_region;
DROP MASKING POLICY IF EXISTS demo_mask_str;
DROP MASKING POLICY IF EXISTS demo_mask_int;
DROP MASKING POLICY IF EXISTS demo_mask_sysget;
DROP MASKING POLICY IF EXISTS demo_mask_direct;
DROP POLICY IF EXISTS demo_row_pol ON bb.bb.demo_salaries;
DROP TABLE IF EXISTS bb.bb.demo_salaries;
Operational Notes
- State persistence. Tag definitions, assignments, and policy bindings are persisted to the catalog. They survive coordinator restarts and replicate to every coordinator in an HA cluster.
INSERT VALUESvsCTAS. PlainINSERT INTO … VALUESwrites through a DML staging buffer that flushes asynchronously; the rows aren't visible to follow-up queries in the same session.CREATE TABLE … AS SELECTwithUNION ALLliteral rows persists immediately. Use CTAS for demo / setup scripts.CREATE POLICYauto-fires on fresh tables. A row-access policy created viaCREATE POLICYis implicitly active on the target table unless RLS is explicitly disabled. To demonstrate that only the tag binding drives a row filter, prefix the section withALTER TABLE … DISABLE ROW LEVEL SECURITY.SHOW TAG REFERENCESreturns the (object_type, catalog, schema, name, column, value) tuple for every active tag assignment. Filter withTAG_NAME = '<tag>'for a single tag.- Bypass roles. Realm roles
superuserandmasking_bypassshort-circuit the masking walk;superuser,ACCOUNTADMIN, andrls_bypassshort-circuit the RLS walk.