Fine-grained policy: row filters, column masking, tag-based masking (next-steps notes)
Future phase, not yet built. Design notes for row-level filtering, column
masking, and tag-based masking in SQE, driven by Apache Ranger, reaching rough
parity with Snowflake’s row-access + masking + tag-masking. Pairs with the
coarse Ranger access-control backend already shipped (catalog/table allow-deny
via Polaris) and with docs/s3vending.md.
Why this lives in SQE, not Polaris
Polaris + Ranger gives a COARSE allow/deny per catalog operation (LOAD_TABLE,
CREATE_NAMESPACE, …). The Polaris service-def declares no rowFilterDef /
dataMaskDef, and the Polaris authorizer reads only the boolean decision, so
Polaris cannot enforce row filters or column masks even though the Ranger engine
can compute them. Fine-grained enforcement has to happen in the query engine.
Two enforcement models:
- Push/sync (Snowflake, closed engine). You cannot intercept Snowflake’s
planner, so Privacera PolicySync translates Ranger policies into Snowflake
native objects on a schedule:
GRANT/REVOKE,CREATE ROW ACCESS POLICY,CREATE MASKING POLICY, objectTAG+ tag-based masking. Snowflake enforces natively at query time; Ranger is the source of truth and the compiler. - Pull/rewrite (SQE, open engine). SQE owns its
LogicalPlanand already enforces by rewriting it:PolicyEnforcer::evaluate()runs between planning and optimization, injects row-filterFilternodes above the scan, swaps columns for mask expressions, and DROPS denied columns entirely (PostgreSQL-RLS model, strictly more expressive than Snowflake’s mask-to-NULL workaround).
SQE should keep pull/rewrite. The work is not the model (it exists) but the policy VOCABULARY to express Snowflake-equivalent policies, plus a Ranger-backed policy source.
Which Ranger service-def (authoritative: docs/ranger-fine-grained-service-type.md)
Fine-grained policies do NOT go on the polaris service (it has no
dataMaskDef/rowFilterDef - coarse allow/deny only). Use the hive
service-def (the service Apache Spark’s Kyuubi Ranger plugin reads) so SQE and
Spark share one policy set, plus a linked tag service for tag-based masking.
Key consequences that shape this phase:
hiveresources aredatabase -> table -> column(NO catalog level). SQE must flatten Iceberg catalog + namespace into thedatabasestring using the SAME convention Kyuubi uses, or cross-engine policies silently won’t match.- policyType integers:
0 = access,1 = DATAMASK,2 = ROWFILTER. - mask transformers in the service-def are Hive UDFs (
mask,mask_show_last_n,mask_hash); SQE reimplements them as DataFusion UDFs or rewrites them. - pull everything in one call via the plugin DOWNLOAD endpoint (below).
Ranger policy type -> where it is enforced
| Ranger policy type | Service | Enforcement | Status |
|---|---|---|---|
| resource access (catalog/ns/table allow-deny) | polaris | Polaris (embedded Ranger authorizer) | shipped |
| row-filter (policyType 2) | hive | SQE PlanRewriter (Filter above scan) | this phase |
| data-mask (policyType 1) | hive | SQE PlanRewriter (column -> mask expr) | this phase |
| tag (mask/row-filter) | tag linked to hive | SQE, via tag-resource associations | this phase |
A single Ranger policy store drives both: the coarse Polaris gate (already wired)
and SQE’s fine-grained rewriter (new RangerStore: PolicyStore).
What SQE has today
sqe-policy/src/lib.rs:PolicyEnforcer,PolicyStore,ResolvedPolicy { row_filters: Vec<Expr>, column_masks, restricted_columns },MaskType { Nullify, Redact(const), Hash, Custom(Expr) }.sqe-policy/src/plan_rewriter.rs: the pull/rewrite enforcement point.sqe-policy/src/sha256_udf.rs: the only registered policy mask UDF (HMAC-SHA256 for the Hash mask).sqe-core/src/session.rs:SessionUser { username, roles: Vec<String> }(flat role list, no hierarchy / secondary roles).OpaStorealready implementsPolicyStore(resolve row filters + masks from OPA) and is the template for aRangerStore.
What to add (the function/primitive vocabulary)
-
Session-context SQL functions (the biggest gap). SQE advertises
CURRENT_USER/CURRENT_SCHEMAin Flight SQLgetSqlInfometadata but does NOT register them as evaluable functions. Register, resolved per-session fromSessionUser:current_user(),current_role(),current_database(),current_schema()is_role_in_session(role),current_available_roles()/current_secondary_roles()equivalents- Prerequisite: a richer role model.
SessionUser.rolesis a flatVec<String>with no hierarchy / secondary-role notion;is_role_in_sessionneedssqe-authto surface the full active + inherited role set into the SQL eval context. This is the real work behind the context functions.
-
Mask types (extend
MaskTypeso policies are declarative, not allCustom). Have: nullify, constant/redact, hash, custom-SQL. Add as first-class: partial/substring (e.g. show last 4) and regexp_replace. Each maps to a Snowflake masking CASE body (THEN val ELSE <transform>). -
Row-filter mapping-table idiom. The Filter-injection mechanism exists; add a first-class lookup-table pattern (an
EXISTS/ semi-join against a mapping table keyed on the session role), the idiom Snowflake row-access policies lean on. Row-filter expressions reference the session-context functions from (1). -
Tag-based masking (this is where tagging belongs). A tag store -> masking- policy binding -> auto-apply path: assign a mask to a tag once, tag a column, masking applies automatically and new matching columns inherit it. SQE’s natural tag source is Iceberg / Polaris column properties plus Ranger tag policies. Note: Ranger tag policies need a tag source; for the Polaris gate there is none today (no Atlas hook / tagsync for Polaris resources), but SQE can read Iceberg column properties directly as the tag source for its own masking, independent of the Polaris path.
Component to build
RangerStore: PolicyStore (new, modeled on OpaStore):
- Reads a
hive-type Ranger service (the one Spark uses), NOT thepolarisservice. Pull the whole bundle in one call:GET /service/plugins/policies/download/{serviceName}returnsServicePolicies= resourcepolicies[](access=0, datamask=1, rowfilter=2) +serviceDef(mask transformer templates, rowFilterDef) +tagPolicies+policyVersion(304 on unchanged -> cheap polling). The public-v2/api/policyarray is resource-only and insufficient. resolve(user, table, namespace) -> ResolvedPolicy: flatten the Iceberg catalog/namespace/table to the hivedatabase/table/columnnaming (match Kyuubi’s convention), select matching row-filter + data-mask (+ tag) items for the user, and return row_filters + column_masks + restricted_columns. Match on the user +SessionUser.rolesDIRECTLY (SQE’s session roles come from the token, unlike the Polaris gate which needs Ranger role membership). Evaluation order: tag policies first, deny-overrides, then resource access -> mask -> row-filter. TranslatefilterExpr/valueExprfrom Hive/Spark dialect to DataFusion; realize Hive mask transformers as DataFusion UDFs (see the mask table indocs/ranger-fine-grained-service-type.md).- Wire it as a
PolicyEngine::Rangervariant in config, feeding the existingPolicyEnforcer/PlanRewriter(which today runs passthrough). - Cache + fail-closed like
OpaStore(usepolicyVersionfor incremental refresh).
See docs/ranger-fine-grained-service-type.md for the full service-type
rationale, the mask-type mapping, the flattening sharp edge, and cross-engine
sharing requirements.
Snowflake context functions to mirror (reference)
CURRENT_USER, CURRENT_ROLE, CURRENT_AVAILABLE_ROLES,
CURRENT_SECONDARY_ROLES, IS_ROLE_IN_SESSION(role), INVOKER_ROLE,
CURRENT_ACCOUNT, CURRENT_DATABASE, CURRENT_SCHEMA, POLICY_CONTEXT (test).
The role-hierarchy ones (IS_ROLE_IN_SESSION) are what require the richer
SessionUser role model.
Phase shape
Phase 2A (shipped, branch a feature branch). The full Ranger hive built-in mask
vocabulary is implemented and wired. Steps 3, 4, and 6 from the original list are done:
MaskTypeextended withPartialMask { show_first, show_last, upper, lower, digit }andDateShowYear.RangerStoremaps every standarddataMaskTypestring to the correspondingMaskTypevariant.mask_partialDataFusion UDF realises the Hive char-level transformer (uppercase, lowercase, digit substitution chars; punctuation and non-ASCII pass through unchanged; Unicode scalar counting). The full hive set is now: MASK_NULL, MASK, MASK_SHOW_LAST_4, MASK_SHOW_FIRST_4, MASK_HASH, MASK_DATE_SHOW_YEAR, CUSTOM.- Quickstart
polaris-ranger-keycloakupdated: orders table gains anssn VARCHARcolumn; aMASK_SHOW_LAST_4policy seeds on that column for roleengineer; test.sh section 5 proves111-11-1111becomesxxx-xx-1111for bob (engineer) and stays raw for alice (analyst-only).
Phase 3a (shipped, branch a feature branch). Tag-based masking enforcement:
TagSourcetrait +NoopTagSourcedefault +CacheTagSourceproduction implementation. Tags stored as Icebergsqe.column-tagstable property.PolicyStore::resolve_tagsdefault no-op;RangerStoreoverrides to fetchtagPoliciesfrom the Ranger bundle and return(tag_masks_by_tag, tag_filters, unmappable_tags).PolicyPlanRewriterwired: callstag_source.column_tags(catalog, full-namespace-vec, table)using the FULL namespace path (not last component), thenresolve_tags, thenmerge_tag_masks. Precedence rules: resource mask wins, restricted stays restricted, unmappable tag fails closed.- Four executable integration tests prove: tag mask end-to-end, full-namespace identity (FakeTagSource capture), resource-mask-wins precedence, unmappable-tag fail-closed.
- NOT live-demoed (quickstart stack drift); the executable tests are the proof.
- Phase 3b (shipped): CUSTOM tag masks + cache invalidation on
SET TBLPROPERTIES.
Phase 3b (shipped, branch a feature branch). CUSTOM tag mask support + cache invalidation:
TagMaskSpecenum (Ready(MaskType)orCustom(String)) replaces the rawMaskTypeinPolicyStore::resolve_tagsreturn type. CUSTOM masks carry the raw{col}template; the rewriter substitutes the column name at merge time.resolve_tag_policiesinranger_store.rs: CUSTOM tags with avalue_exprnow produceTagMaskSpec::Custom(template)instead of being marked unmappable. Tags with novalue_exprremain unmappable (fail-closed, nothing to substitute).merge_tag_masksinplan_rewriter.rs:TagMaskSpec::Custom(template)branches substitute{col}with the real column name, callparse_sql_predicate, produceMaskType::Custom(expr)on success, restrict column on parse failure (fail-closed).set_table_propertiesincatalog_ops.rs: explicitsession_catalog.invalidate_tablecall aftercommit_schema_updateso aSET TBLPROPERTIES('sqe.column-tags'=...)is visible on the next query without waiting for TTL expiry.invalidate_policy_cache()also called inQueryHandlerfor defense-in-depth.- Two new tests cover: CUSTOM with valid expression applies
MaskType::Custom(not restriction); CUSTOM with unparseable expression restricts the column.
Phase 2B (not yet started). Session-context SQL functions:
sqe-auth/SessionUser: richer role model (active + inherited/secondary).- Register
current_user(),current_role(),current_available_roles()as evaluable scalar UDFs resolved per-session fromSessionUser.
Phase 2C (not yet started). Cross-engine dynamic transformer + tag-based masking:
- Dynamic transformer configuration for arbitrary-N show-first/show-last masks (currently hard-coded to 4).
- Tag-based masking: Iceberg column-property tag source -> mask binding, plus Ranger tag policies. The tag source for the Polaris gate has no Atlas hook today, but SQE can read Iceberg column properties directly.
Sources
- Snowflake row access policies: https://docs.snowflake.com/en/user-guide/security-row-intro
- Snowflake masking policies: https://docs.snowflake.com/en/sql-reference/sql/create-masking-policy
- Snowflake tag-based masking: https://docs.snowflake.com/en/user-guide/tag-based-masking-policies
- Snowflake context functions: https://docs.snowflake.com/en/sql-reference/functions-context
- Snowflake IS_ROLE_IN_SESSION: https://docs.snowflake.com/en/sql-reference/functions/is_role_in_session
- Privacera PolicySync (Ranger -> Snowflake push model): https://docs.privacera.com/resources/design/access-management/integrations/privacera_policysync.html
- Apache Ranger policy model (row-filter / data-mask / tag types): https://ranger.apache.org/blogs/policy_model.html