Skip to main content

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 mask VARCHAR columns one way and BIGINT columns 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:

  1. Column — ALTER TABLE t ALTER COLUMN c SET TAG ...
  2. Table — ALTER TABLE t SET TAG ...
  3. Schema — assignments scoped to a schema (object_type = 'SCHEMA')
  4. 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 typeMaps to (DataTypeName)Matches policy declared as
Utf8 / LargeUtf8STRING(STRING) / (VARCHAR) / (TEXT) / (CHAR)
Int8 / Int16 / Int32INT(INT) / (INTEGER)
Int64LONG(BIGINT) / (LONG) / (INT64)
Float32FLOAT(FLOAT) / (REAL)
Float64DOUBLE(DOUBLE) / (FLOAT64)
Decimal128/256DECIMAL(DECIMAL) / (NUMERIC)
Date32 / Date64DATE(DATE)
Timestamp(_, _)TIMESTAMP(TIMESTAMP) / (DATETIME)
BooleanBOOLEAN(BOOLEAN) / (BOOL)
Binary / LargeBinaryBINARY(BINARY) / (BYTES) / (VARBINARY)
Pick the policy type carefully

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 VALUES vs CTAS. Plain INSERT INTO … VALUES writes through a DML staging buffer that flushes asynchronously; the rows aren't visible to follow-up queries in the same session. CREATE TABLE … AS SELECT with UNION ALL literal rows persists immediately. Use CTAS for demo / setup scripts.
  • CREATE POLICY auto-fires on fresh tables. A row-access policy created via CREATE POLICY is 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 with ALTER TABLE … DISABLE ROW LEVEL SECURITY.
  • SHOW TAG REFERENCES returns the (object_type, catalog, schema, name, column, value) tuple for every active tag assignment. Filter with TAG_NAME = '<tag>' for a single tag.
  • Bypass roles. Realm roles superuser and masking_bypass short-circuit the masking walk; superuser, ACCOUNTADMIN, and rls_bypass short-circuit the RLS walk.