What DataSecOps is
Link to section “What DataSecOps is”In 30 seconds
Link to section “In 30 seconds”DataSecOps is the practice of running a data platform so that protection of the data itself is continuous and automated: every dataset is classified, access is decided at the level of rows and columns at query time, sensitive values are masked, tokenised or encrypted according to who is asking, and every read is logged well enough to prove the controls worked. The label was coined by Satori Cyber, a data-access security vendor, whose chief scientist Ben Herzberg described it in 2021 as “an agile, holistic, security embedded approach to coordinating of the ever-changing data and its users” and argued that “we can’t allow DataOps to come without security bolted into it”.[1] This page keeps the idea (security as a property of the data operation, not an end-of-line audit) and drops the marketing: there is no DataSecOps standard, body or certification, and the practice has older names such as data security governance.
For data platform engineers who implement the controls, security and privacy leads who define them, data owners who approve access, and leaders deciding whether to buy a platform or use what the warehouse already offers.
At a glance
Link to section “At a glance”| Item | Summary |
|---|---|
| Term status | Vendor-specific. Defined and promoted by Satori Cyber (2021);[1][2] part of Commvault since 2025-08-28.[3] Analysts (Gartner) call the tooling “data security platforms”;[4] vendors also sell “data security posture management”;[5] neither uses “DataSecOps”. |
| Goal | Fast, approved access without over-exposure, and evidence that sensitive fields were seen only by those entitled. |
| In scope | Discovery and classification; row-, column- and cell-level policy; masking, tokenisation, format-preserving encryption, pseudonymisation and de-identification; keys as they touch data platforms; egress controls and DLP; secure sharing; data-access audit; access reviews. |
| Out of scope | Pipelines → DataOps; security telemetry → SecDataOps; lawful basis and DSARs → PrivacyOps; identities → IAMOps; privileged access → AccessOps; database engines → DBOps; cloud posture → CloudSecOps. |
| Prerequisites | A working pipeline and catalogue habit (DataOps); basic identity and role design (IAMOps). |
| Key outputs | A classification manifest per dataset; policy bundles in version control; a request and review workflow; an access-history report; a residual-risk statement for de-identified releases. |
The problem DataSecOps solves
Link to section “The problem DataSecOps solves”Data platforms concentrate value and exposure: a warehouse that joins customers, payments and tickets is more useful than any source system and more dangerous, because one credential, one over-broad role or one public bucket reaches everything. In 2024 the group Mandiant tracks as UNC5537 “is systematically compromising Snowflake customer instances using stolen customer credentials”. The affected accounts had no multi-factor authentication, so “successful authentication only required a valid username and password”; no network allow lists were in place; “at least 79.7% of the accounts leveraged by the threat actor in this campaign had prior credential exposure” through infostealer malware, in several cases on contractor systems; and “in many cases, the credentials had not been rotated for as long as four years”. About 165 organisations were notified; Mandiant found no evidence that the platform itself was breached.[6]
The other shape is the storage perimeter: Capital One’s 2019 incident came through “a configuration vulnerability” and “affected approximately 100 million individuals in the United States”;[7] the OCC “assessed an $80 million civil money penalty”.[8] That control plane belongs to CloudSecOps; this page keeps only the “no public buckets” check. IBM’s 2026 study puts the global average breach cost at USD 4.99 million, “a record high”.[9] The quieter costs are analysts waiting weeks for access, production copied into test, and nobody able to say who read the salary column last quarter.
What DataSecOps is not: naming and boundaries
Link to section “What DataSecOps is not: naming and boundaries”It is also not:
- PrivacyOps. Both pages discuss masking and minimisation; this page owns the technical controls, PrivacyOps owns lawful basis, DSARs and the legal decision that a field must be minimised at all.
- DataOps with a security chapter. Satori’s framing is that DataOps “will add security but typically in the final stage as a check or audit” while DataSecOps “integrates security throughout the entire process”.[10] Operationally: classification and policy are produced by the same pipeline that produces tables.
- A product category. Gartner’s 2025 market guide, as reprinted by a vendor, says data security platforms “combine data security controls with business logic and fine-grained authorization”.[4]
- Backup, recovery or storage: BackupOps, DisasterRecoveryOps, StorageOps.
Where the label comes from and where it sits
Link to section “Where the label comes from and where it sits”Origins and evolution of the label
Link to section “Origins and evolution of the label”The practice predates the name. PostgreSQL added row-level security in 9.5 (January 2016),[11] and Apache Ranger added row filtering and column masking in release 0.6, enforced by Apache Hive 2.1 and later.[12] What changed was the move of enterprise data into shared cloud warehouses, where access multiplied faster than controls. The label’s history is short:
- 2021-08-04. Help Net Security publishes a podcast transcript in which Ben Herzberg, Chief Scientist at Satori, defines DataSecOps, draws the DevOps → DevSecOps parallel and lists five principles, from continuous processes over ad hoc projects to fast access without weakening security.[1]
- 2021–2025. Satori’s site carries the definition and seven principles;[2] other vendors publish near-identical definitions without attribution.[13]
- 2025-08-28. Commvault completes “the acquisition of 100% of the shares of Satori Cyber, Ltd.”, “an Israel-based data and AI security company”, for a preliminary USD 28.3 million in cash.[3]
- 2026. The label survives in vendor blogs and at least one bank job title (a “DataSecOps” security engineer at Itaú Unibanco, per a Brazilian training provider, whose duties are application security);[14] a search for 2025–2026 material returns little else.
Glossary and mental model
Link to section “Glossary and mental model”| Term | Meaning | Why it matters |
|---|---|---|
| Direct identifier / quasi-identifier / sensitive attribute | Names a person alone (email, national ID); identifies in combination (ZIP, birth date, sex); harms if disclosed (diagnosis, salary). Snowflake’s classifier uses the same three categories.[15] | Sweeney found 87% of the US population likely unique on ZIP, gender and birth date alone.[16] |
| Classification | A semantic category and sensitivity level per column, recorded as an enforceable tag. | Policies attach to tags; an untagged sensitive column is unprotected. |
| Row-level security / row access policy | A predicate added to every query so a principal sees only permitted rows. | One table serves many audiences; brings covert channels. |
| Column-level security / masking policy | Deny a column or transform its value at query time depending on the caller. | Display masking is not unreadable storage; PCI SSC says the terms “are not meant to be used interchangeably”.[17] |
| Tokenisation | Replace a value with an unrelated token held in a vault; “The original sensitive value cannot be recovered from a token alone.”[18] | Removes the value from the platform; the vault becomes critical. |
| Pseudonymisation | GDPR Art. 4(5): processing “in such a manner that the personal data can no longer be attributed” “to a specific data subject without the use of additional information” kept separately.[19] The EDPB calls the context that must stay isolated the pseudonymisation domain.[20] | Personal data for the key holder; a recipient unable to re-identify may not hold personal data (CJEU, 2025).[21] Risk falls; obligations remain. |
| De-identification | Transforming data until re-identification risk is acceptably low: removing direct identifiers, generalising quasi-identifiers, synthetic data, k-anonymity, differential privacy.[22] | A release decision with measured residual risk, not a masking function. |
Mental model: a locked table with a lens. Each sensitive table sits behind a lens ground differently for every principal. The row policy decides which rows it shows; the masking policy decides how sharply each column is shown; the tag decides which lens applies; the access history records every look. DataSecOps is grinding, fitting and inspecting those lenses as tables, people and laws change.
How DataSecOps relates to other Ops
Link to section “How DataSecOps relates to other Ops”Diagram source (Mermaid)
flowchart TB DO["DataOps"]:::neutral IAM["IAMOps"]:::neutral PR["PrivacyOps"]:::neutral AC["AccessOps"]:::neutral DS["DataSecOps: who sees which rows and columns"]:::core AN["AnalyticsOps"]:::neutral SD["SecDataOps"]:::neutral CO["ComplianceOps"]:::neutral DO -->|"tables, owners"| DS IAM -->|"roles, groups"| DS PR -->|"fields to minimise"| DS AC -->|"privileged sessions"| DS DS -->|"masked tables"| AN DS -->|"access logs"| SD DS -->|"review records"| CO classDef core fill:#e8f3f2,stroke:#16756f,color:#122029 classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
Text equivalent: DataOps supplies tables and owners; IAMOps supplies identities, roles and groups; PrivacyOps decides which fields must be minimised; AccessOps handles break-glass privileged sessions. DataSecOps turns these into enforced row and column policies and hands masked tables to AnalyticsOps, access logs to SecDataOps and review records to ComplianceOps.
| Neighbour | Unique focus there | Handoff |
|---|---|---|
| DataOps | Pipeline flow, tests, data incidents | DataOps keeps personal data out of test outputs; this page sets the masking rules it applies |
| SecDataOps | Telemetry coverage, parsing, SIEM cost | Data-platform access history is a log source for SecDataOps |
| PrivacyOps | Legal process, DSARs, records of processing | PrivacyOps says what must be minimised; this page says how and proves it |
| IAMOps | Identity-centric policy across all systems | Platform roles and groups come from IAMOps; row and column policy stays here |
| AccessOps | Just-in-time elevation, session recording | Break-glass to a warehouse is AccessOps; standing analyst access is here |
| ComplianceOps | Frameworks, audits, control testing | Access-review records and policy coverage reports are the evidence |
Principles and operating model
Link to section “Principles and operating model”Charter and principles
Link to section “Charter and principles”Fundamentals: the building blocks
Link to section “Fundamentals: the building blocks”Nine blocks; the tooling landscape names products for each.
- Discovery and classification: scanners propose, humans confirm.
- Identity and roles: groups, MFA, network policies; Snowflake is phasing out password-only sign-ins through 2026.[24]
- Fine-grained policy: row, column and cell policy inherited through tags, enforced at query time.
- Value transformation: masking, hashing, tokenisation, FPE and de-identification, each with different guarantees (deep comparisons).
- Encryption and keys: Snowflake rotates hierarchy keys older than 30 days;[25] AWS KMS keeps HSM keys from leaving in plain text.[26]
- Storage perimeter and egress (next paragraph).
- Sharing through read-only shares and secure views.
- Audit and lineage: who read what under which policy; column lineage with a
maskingflag.[27] - Workflow: request, approve, grant, review, revoke.
Egress controls: where DLP sits in a data platform. Query-time policies decide what a query returns; egress controls decide
where a result may go. The 2024 campaign exfiltrated with COPY INTO to stages,[6] an ordinary unload. Snowflake exposes
the levers as account or user parameters, all defaulting to FALSE:[28]
PREVENT_UNLOAD_TO_INLINE_URL no ad hoc unloads to cloud storage URLs
PREVENT_UNLOAD_TO_INTERNAL_STAGES no unloads to internal stages
REQUIRE_STORAGE_INTEGRATION_FOR_STAGE_CREATION external stages need an integration
REQUIRE_STORAGE_INTEGRATION_FOR_STAGE_OPERATION stage operations must use one
DISABLE_UI_DOWNLOAD_BUTTON no download button in Snowsight
On the file side, Macie raises a policy finding when a bucket becomes public[29] and Purview DLP acts on documents, mail and (in preview) Microsoft 365 Copilot.[30] Content-inspecting DLP adds little on a warehouse whose columns are already classified; the stronger control is structural: no stage without an integration, unloads alerted, downloads limited. The alerting pipeline belongs to SecDataOps.
Roles, ownership and RACI
Link to section “Roles, ownership and RACI”| Activity | Data owner | Platform / data security engineer | Security or privacy officer | Consumer (analyst, pipeline, partner) |
|---|---|---|---|---|
| Classify a new dataset | A | R (runs discovery, proposes tags) | C | I |
| Approve a standard access request | A/R | I | I | R (requests) |
| Approve an exception (plain-text identifiers, export) | A | C | R (risk sign-off) | I |
| Write or change a masking or row policy | C | R | A | I |
| Quarterly access review | A/R | R (produces the report) | C | I (attests own need) |
| De-identified external release | A | R (applies techniques) | A (residual-risk sign-off) | I |
| Respond to a data-access incident | C | R | A | I |
Snowflake’s own design puts the decision about which columns to protect with “a security or privacy officer”, not the object owner,[31] which matches the RACI: owners approve who, security defines how.
Lifecycle: Day 0, Day 1, Day 2
Link to section “Lifecycle: Day 0, Day 1, Day 2”- Day 0 (design). Classification scheme (three to five levels), identity model, repository layout, non-production account, and the default: new IDENTIFIER columns are masked until granted.
- Day 1 (rollout). The first 60 days of the adoption roadmap.
- Day 2 (operate). Weekly: classify new tables, merge policy changes. Monthly: coverage and plain-text read reports. Quarterly: access reviews, exception expiry, credential checks. Continuously: drift and new exposure paths (copies, extracts, agents).
Operating model and governance
Link to section “Operating model and governance”- Policies. A classification standard, an access policy (approvers, maximum grant duration, exception rules), a sharing policy and a de-identification policy (when a release needs expert review).
- Workflow. Request → owner approval → pull request → CI → apply → confirmation with expiry. Exceptions carry a ticket, a justification, a risk sign-off and an end date that the policy itself enforces.
- Controls on the controls. Policy objects belong to a dedicated role; nobody writes policy in the console; the platform’s
policy catalogue (
POLICY_REFERENCESin Snowflake[31]) is diffed against the repository. - Feedback and escalation. Access history drives reviews and reviews drive revocations. Plain-text identifier access outside policy, a public bucket or a dormant admin credential opens an incident (IncidentOps; ResponseOps if exfiltration is suspected).
Architecture and workflow
Link to section “Architecture and workflow”Reference architecture: a data protection control plane
Link to section “Reference architecture: a data protection control plane”The architecture separates a control plane (classification, policy, evidence) from the data planes that enforce and the consumers. The control plane is not in the query path; the platforms enforce. Overlay products differ mainly in whether they push policy down into the platform or proxy the query path.
Diagram source (Mermaid)
flowchart TB
subgraph CP["Control plane: decides, never in the query path"]
DISC["Discovery and classification"]:::core
REQ["Access requests and approvals"]:::decision
REPO["Policy repository in Git: tags, masking, row policies"]:::core
CI["CI: lint, test in non-prod, apply"]:::core
DISC --> REPO
REQ --> REPO
REPO --> CI
end
DPL["Data planes enforce at query time: warehouse, lakehouse, OLTP database, object storage"]:::neutral
CON["Consumers: analysts and BI, pipelines, partners via shares"]:::neutral
AI["AI agents: delegated identity, broad reads"]:::risk
AUD["Access history and lineage store"]:::core
REV["Quarterly review: revoke or renew by pull request"]:::decision
CI -->|"policies and tags"| DPL
DPL -->|"filtered, masked results"| CON
DPL -->|"same policies"| AI
DPL -->|"logs"| AUD
AUD --> REV
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
Text equivalent: in the control plane at the top, discovery and classification and access requests both feed a Git policy repository; CI lints, tests in non-production and applies policies and tags to the data planes (warehouse, lakehouse, OLTP database, object storage), which enforce at query time. They return filtered, masked results to consumers, apply the same policies to AI agents (marked higher risk) and send logs to the access-history and lineage store, which feeds a quarterly review whose revocations and renewals return to the repository as pull requests.
Component responsibilities and trust boundaries.
- Discovery proposes; humans dispose. Google’s profiler “cannot commit to 100% accuracy across all scenarios”;[32] the tag records the human decision.
- The repository is the source of truth. A policy attached directly to a column “takes precedence over the tag-based masking policy” in Snowflake,[33] so the repository must own both kinds or drift is invisible.
- CI applies with a dedicated policy-owner role that reads the mapping tables and nothing else.[34]
- Data planes enforce reads; writes, views, clones and unloads have limits of their own (see quality and security).
- AI agents have delegated identity, broad reads and the ability to restate what they read: give them the masked, filtered path of a new analyst, never a job’s service role.
End-to-end workflow: from request to enforced, evidenced access
Link to section “End-to-end workflow: from request to enforced, evidenced access”Diagram source (Mermaid)
sequenceDiagram autonumber participant U as Requester participant O as Owner participant R as Repo + CI participant P as Platform U->>O: Dataset, purpose, duration O->>R: Approve in a PR Note over O,R: Exceptions add privacy sign-off R->>P: Test, then apply in prod R-->>O: Applied, expiry recorded O-->>U: Granted until expiry Note over U,P: Queries return only permitted rows and masked columns P->>R: Access history (quarterly) R->>O: Access report O->>R: Revoke or renew (PR)
Text equivalent: the requester asks the owner for a dataset, with purpose and duration; the owner approves in a pull request (an exception also needs a privacy sign-off); CI tests in non-production, applies in production and reports the grant applied with its expiry; the owner tells the requester access lasts until that date. From then on queries return only permitted rows and masked columns. Each quarter access history reaches CI, which reports to the owner, who revokes or renews by pull request.
Happy path. A standard request (masked view of a tagged dataset) should close in a working day: it changes a group membership, not a policy. Exception path. Plain-text identifiers, exports and shares change the exposure, so they need a named approver, a risk note, an expiry and a review entry. Measure lead time per path, the exception share and grants without a request record.
Practices, frameworks and tools
Link to section “Practices, frameworks and tools”Core practices and playbooks
Link to section “Core practices and playbooks”| Practice | Goal | Trigger | Owner | Output | Verification |
|---|---|---|---|---|---|
| Continuous discovery | No unclassified sensitive column older than a week | New table, schema change, weekly schedule | Platform engineer | Scanner findings triaged into tags | Coverage report: tagged ÷ flagged |
| Tag-driven protection | A new tagged column is protected before it is granted | Tag applied | Policy repo (CI) | Masking or deny policy via tag | Query as a plain role; value is masked |
| Access review | Remove dormant and over-broad access | Quarter end | Data owner | Dated attestation, revocations | Dormant-grant ratio falls |
Playbook: protect a new sensitive table. (1) The table is catalogued with an owner; discovery runs and the owner confirms
each flagged column within five working days. (2) Add columns and classifications to the manifest; CI generates tag assignments.
(3) Confirm a masking policy exists on the tag for every tagged column’s data type.[33] (4) Apply in non-production; run
the acceptance query as an unprivileged role and as an exempt role; compare. (5) Apply in production; capture POLICY_REFERENCES.
Acceptance: unprivileged reads are masked, exempt reads are plain, access history shows the policy. Rollback: revert the pull
request; keep the table unshared until acceptance passes.
Methods, standards and frameworks
Link to section “Methods, standards and frameworks”Separate what is normative for you (law, contracts, sector rules) from recommended (standards catalogues); jurisdiction and sector decide the first group, and the list is not legal advice.
| Source | Type | What it gives DataSecOps |
|---|---|---|
| NIST SP 800-53 Rev. 5 (Release 5.2.0, 2025-08-27);[35] NIST CSF 2.0[36] | Catalogue / framework | Control families (Access Control, Audit and Accountability) and “a taxonomy of high-level cybersecurity outcomes” for reporting upward |
| NIST SP 800-188 (2023)[22] | Guidance | De-identification techniques, disclosure review boards, re-identification studies |
| NIST SP 800-226 (2025)[37] | Guidance | Differential privacy as “a mathematical framework that quantifies privacy loss” |
| NIST SP 800-207 (2020); CISA ZTMM v2.0[23][38] | Framework | Zero trust as protecting resources; stages “traditional, initial, advanced, and optimal” |
| GDPR Art. 4(5), 25, 32[19][39][40] | Normative (EU/EEA) | Pseudonymisation defined; “by default personal data are not made accessible without the individual’s intervention”; measures including “the pseudonymisation and encryption of personal data” |
| EDPB Guidelines 01/2025 (adopted 2025-01-16, consultation version)[20] | Regulatory guidance (draft) | The pseudonymisation domain; “pseudonymisation alone will normally not be a sufficient measure”; treats pseudonymised data as personal whoever holds them |
| CJEU C-413/23 P, EDPS v SRB (2025-09-04)[21] | Case law (EU) | Pseudonymised data “must not be regarded as constituting, in all cases and for every person, personal data”; a recipient that cannot re-identify may hold non-personal data |
| HIPAA 45 CFR 164.514(b)[41] | Normative (US covered entities) | Expert Determination or Safe Harbor’s 18 identifiers; initial three ZIP digits become 000 for units of 20,000 or fewer people; ages over 89 removed or aggregated |
| PCI DSS v4 Req. 3.4.1 and 3.5.1 (PCI SSC FAQ)[17] | Contractual (card data) | Display masking versus unreadable storage; “Masking is not synonymous with truncation” |
Tooling landscape
Link to section “Tooling landscape”Named products were verified against their documentation on 2026-10-17 and will change.
| Category | Verified examples | Selection criteria and risks |
|---|---|---|
| Platform-native fine-grained access | Snowflake (Enterprise+);[31] BigQuery;[42] Unity Catalog (parts Beta);[43] Lake Formation;[44] PostgreSQL;[45] Apache Ranger[46] | No extra vendor; one dialect per platform; know precedence, covert channels and edition tiers |
| Discovery and classification | Snowflake classification;[15] Google Sensitive Data Protection (Google Cloud, S3, Azure Blob);[32] Macie;[29] Presidio (community-owned)[47] | Accuracy never 100%; cost per byte; where findings land |
| Overlay data security platforms | Commvault (Satori),[3] Immuta, Privacera and others; Gartner frames the category[4] | Proxy vs push-down; policy-language lock-in; where evidence lives |
| Tokenisation, FPE, masking services | Vault Transform (Enterprise ADP; FF1 FPE, masking, tokenisation)[18] | Vault availability becomes data availability |
| Key management | AWS KMS;[26] Snowflake composite customer-managed keys (key outage tolerated up to 10 minutes)[25] | A revoked key stops the platform |
| Storage perimeter and egress | S3 Block Public Access at organisation, account and bucket level;[48] Snowflake unload and stage parameters;[28] Purview DLP for documents[30] | Old buckets and policies still need review; content DLP is weak on structured data |
| Sharing | Snowflake Secure Data Sharing with secure views;[49][50] Delta Sharing[51] | Region limits; secure views are slower; consumer logging is theirs |
| Audit and lineage | ACCESS_HISTORY (365 days, up to 3 hours latency);[52] BigQuery Data Access logs;[53] system.access.audit;[54] pgAudit;[55] OpenLineage[56] |
Failed queries missing; superusers may be unauditable[55] |
Comparison of platform-native controls (as of October 2026; edition and version details change).
| Capability | Snowflake | BigQuery | Databricks Unity Catalog | AWS Lake Formation | PostgreSQL |
|---|---|---|---|---|---|
| Row policy | Row access policy, mapping tables, evaluated before masking[34] | Row access policy with FILTER USING; denied unless listed[57] |
Row filter UDF per table[58] | Data filter row expression (PartiQL)[59] | CREATE POLICY permissive/restrictive[60] |
| Column masking | Masking policy, directly or by tag[31][33] | Data policy on a policy tag; fixed rule set plus custom routine[61] | Column mask UDF[58] | Column include/exclude (deny, no transform)[59] | View over the table (no native masking) |
| Tag inheritance | Tag on schema protects new tables[33] | One policy tag per column; taxonomy per region[42] | Governed tags with ABAC policies[43] | LF-Tags inherit database → table → column[44] | None |
| Known limits | Materialized views conflict; inserts not blocked[34][33] | No partition pruning with RLS; side channels via query duration[57] | Not on views; no time travel or clones[58] | Read-only; SELECT only[59] |
Referential-integrity covert channel; owners bypass unless forced[45] |
Automation and AI
Link to section “Automation and AI”Automate discovery (scheduled scans into a queue), policy application (CI, never the console) and reviews (reports from access
history). AI plays two roles; keep them apart. As a classifier: Macie uses “machine learning and pattern matching”;[29]
Snowflake’s classification has an optional AI mode (Preview) that calls an LLM at a token cost.[15]
As a consumer: copilots and agents read whatever their identity can read and restate it elsewhere.[30] Snowflake’s
access history has an agents_info column, “an ordered JSON array of invoking agents”: its own Cortex agents, and an
EXTERNAL_AGENT when an outside agent connects as a SERVICE_AGENT user or through an OAuth integration set with
IS_AGENTIC = TRUE.[52] Give each external agent its own SERVICE_AGENT user;[62] an agent that
borrows a person’s login or a job’s role meets neither condition, so it is not recorded as an agent. Prompt-level filters are a
second layer. LLMOps covers the application side.
DataSecOps in practice
Link to section “DataSecOps in practice”Reference implementation: manifest, policy bundle, pipeline, evidence
Link to section “Reference implementation: manifest, policy bundle, pipeline, evidence”Assumptions. One Snowflake account on Enterprise Edition (masking, row access policies, classification and access history all require it[31][34][15][52]); groups come from the identity provider; a Git repository holds one manifest per dataset and a generated SQL bundle; CI signs in with GitHub’s short-lived OIDC token (workload identity federation, no stored key)[63] as a service user that owns only the policy objects. The same shape works on BigQuery (policy tags, data policies) and Unity Catalog (governed tags, ABAC); only the generated SQL changes.
1. The classification manifest (one per dataset; field names borrow from the Open Data Contract Standard v3.2.0[64]).
Example status: YAML syntax-checked by the validator; illustrative values.
# data-protection/datasets/crm.customers.yaml
dataset: CRM.PUBLIC.CUSTOMERS
owner: crm-data-owners@example.com
steward: asha.steward@example.com
review_cadence: quarterly
default_handling: mask # IDENTIFIER columns are masked unless granted
columns:
- name: CUSTOMER_ID
classification: INTERNAL
- name: FULL_NAME
classification: IDENTIFIER
semantic: NAME
- name: EMAIL
classification: IDENTIFIER
semantic: EMAIL
encryptedName: EMAIL_TOKEN # convergent token kept for joins (Vault Transform)
- name: BIRTH_DATE
classification: QUASI_IDENTIFIER
semantic: DATE_OF_BIRTH
- name: REGION
classification: INTERNAL # never tagged: a row-policy key cannot also
row_scope_key: true # sit in a masking policy signature
plain_text_roles: # exceptions; CI renders GOV.POLICY.PLAINTEXT_GRANTS
- role: SUPPORT_PII
ticket: DP-1042
expires: 2026-12-31
row_scopes:
mapping_table: GOV.POLICY.REGION_GRANTS
2. The generated policy bundle. A tag-based masking policy binds to the tag, not to a tag value: every column carrying
GOV.POLICY.CLASSIFICATION with a matching data type gets it.[33] So there is one policy per data type; its body reads
the column’s tag value with SYSTEM$GET_TAG_ON_CURRENT_COLUMN[33][65] and looks up dated, dataset-scoped
exemptions with the WHEN EXISTS (subquery) form the reference prescribes.[66] The pipeline also tags columns and
attaches the row access policy.[67]
Example status: syntax follows the Snowflake reference pages cited; not executed (requires an Enterprise account).
-- gov/policy/00_tags.sql (account-wide, applied once)
USE ROLE DATA_PROTECTION_ADMIN;
-- INTERNAL is never tagged; GOV.POLICY.DATASET is a table tag (the manifest name).
CREATE TAG IF NOT EXISTS GOV.POLICY.CLASSIFICATION
ALLOWED_VALUES 'QUASI_IDENTIFIER', 'IDENTIFIER', 'SENSITIVE';
CREATE TAG IF NOT EXISTS GOV.POLICY.DATASET;
-- gov/policy/05_exemptions.sql : rendered by CI from the manifests' plain_text_roles
CREATE TABLE IF NOT EXISTS GOV.POLICY.PLAINTEXT_GRANTS
(DATASET STRING, ROLE_NAME STRING, TICKET STRING, EXPIRES DATE);
MERGE INTO GOV.POLICY.PLAINTEXT_GRANTS t
USING (SELECT 'CRM.PUBLIC.CUSTOMERS' DATASET, 'SUPPORT_PII' ROLE_NAME,
'DP-1042' TICKET, '2026-12-31'::DATE EXPIRES) s
ON t.DATASET = s.DATASET AND t.ROLE_NAME = s.ROLE_NAME
WHEN MATCHED THEN UPDATE SET TICKET = s.TICKET, EXPIRES = s.EXPIRES
WHEN NOT MATCHED THEN INSERT VALUES (s.DATASET, s.ROLE_NAME, s.TICKET, s.EXPIRES);
-- (CI also deletes rows whose manifest entry is gone.)
-- gov/policy/10_masking.sql : one policy per data type, branching on the tag VALUE.
-- An attached policy cannot be replaced: create a fail-closed stub once, then
-- change the body in place on every run.
CREATE MASKING POLICY IF NOT EXISTS GOV.POLICY.MASK_CLASSIFIED_STRING
AS (val STRING) RETURNS STRING -> '***';
ALTER MASKING POLICY GOV.POLICY.MASK_CLASSIFIED_STRING SET BODY ->
CASE
WHEN EXISTS ( -- dataset-scoped, dated exemption
SELECT 1 FROM GOV.POLICY.PLAINTEXT_GRANTS g
WHERE g.DATASET = SYSTEM$GET_TAG_ON_CURRENT_TABLE('GOV.POLICY.DATASET')
AND IS_ROLE_IN_SESSION(g.ROLE_NAME)
AND g.EXPIRES >= CURRENT_DATE()
) THEN val
WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('GOV.POLICY.CLASSIFICATION')
= 'QUASI_IDENTIFIER'
THEN LEFT(val, 3) -- generalise (postcode area, job family)
ELSE '***' -- IDENTIFIER, SENSITIVE
END;
CREATE MASKING POLICY IF NOT EXISTS GOV.POLICY.MASK_CLASSIFIED_DATE
AS (val DATE) RETURNS DATE -> NULL::DATE;
ALTER MASKING POLICY GOV.POLICY.MASK_CLASSIFIED_DATE SET BODY ->
CASE
WHEN EXISTS (
SELECT 1 FROM GOV.POLICY.PLAINTEXT_GRANTS g
WHERE g.DATASET = SYSTEM$GET_TAG_ON_CURRENT_TABLE('GOV.POLICY.DATASET')
AND IS_ROLE_IN_SESSION(g.ROLE_NAME)
AND g.EXPIRES >= CURRENT_DATE()
) THEN val
WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('GOV.POLICY.CLASSIFICATION')
= 'QUASI_IDENTIFIER'
THEN DATE_TRUNC('YEAR', val) -- keep the year, drop month and day
ELSE NULL
END;
-- Bind one policy per data type to the tag: every tagged STRING or DATE column.
-- FORCE has no effect on the first run; later runs re-set the same policies
-- (harmless by inference; binding a different policy can cut access).
ALTER TAG GOV.POLICY.CLASSIFICATION
SET MASKING POLICY GOV.POLICY.MASK_CLASSIFIED_STRING,
MASKING POLICY GOV.POLICY.MASK_CLASSIFIED_DATE FORCE;
-- gov/policy/20_row_access.sql : region scoping through a central mapping table
CREATE TABLE IF NOT EXISTS GOV.POLICY.REGION_GRANTS
(ROLE_NAME STRING, REGION STRING);
CREATE ROW ACCESS POLICY IF NOT EXISTS GOV.POLICY.RAP_REGION
AS (region STRING) RETURNS BOOLEAN -> FALSE;
ALTER ROW ACCESS POLICY GOV.POLICY.RAP_REGION SET BODY ->
IS_ROLE_IN_SESSION('CRM_GLOBAL_READER')
OR EXISTS (
SELECT 1 FROM GOV.POLICY.REGION_GRANTS g
WHERE g.REGION = region AND IS_ROLE_IN_SESSION(g.ROLE_NAME)
);
-- generated per dataset from the manifest: datasets/crm.customers.sql
ALTER TABLE CRM.PUBLIC.CUSTOMERS
SET TAG GOV.POLICY.DATASET = 'CRM.PUBLIC.CUSTOMERS';
ALTER TABLE CRM.PUBLIC.CUSTOMERS ALTER COLUMN FULL_NAME
SET TAG GOV.POLICY.CLASSIFICATION = 'IDENTIFIER';
ALTER TABLE CRM.PUBLIC.CUSTOMERS ALTER COLUMN EMAIL
SET TAG GOV.POLICY.CLASSIFICATION = 'IDENTIFIER';
ALTER TABLE CRM.PUBLIC.CUSTOMERS ALTER COLUMN BIRTH_DATE
SET TAG GOV.POLICY.CLASSIFICATION = 'QUASI_IDENTIFIER';
-- CUSTOMER_ID and REGION are INTERNAL: no tag (REGION is the row-policy key).
-- A table holds one row access policy at a time: add it only if it is missing.
EXECUTE IMMEDIATE $$
DECLARE
n INTEGER;
BEGIN
SELECT COUNT(*) INTO :n
FROM TABLE(CRM.INFORMATION_SCHEMA.POLICY_REFERENCES(
REF_ENTITY_NAME => 'CRM.PUBLIC.CUSTOMERS', REF_ENTITY_DOMAIN => 'TABLE'))
WHERE POLICY_NAME = 'RAP_REGION';
IF (n = 0) THEN
ALTER TABLE CRM.PUBLIC.CUSTOMERS
ADD ROW ACCESS POLICY GOV.POLICY.RAP_REGION ON (REGION);
END IF;
END;
$$;
Five load-bearing details:
- A tag holds one masking policy per data type, so a
NUMBERidentifier needs its own policy.[33] - A direct column policy overrides the tag policy, so CI fails on direct attachments the manifest does not list.[33]
- “The same column cannot be specified in both a row access policy signature and a masking policy signature at the same
time”,[34] and a tag-bound policy puts every tagged column of its type into a masking signature; so CI never tags a
row_scope_keycolumn, and INTERNAL columns stay untagged. - A row access policy body runs with the policy owner’s role, so
DATA_PROTECTION_ADMINmust readREGION_GRANTS;[34] the masking reference does not name the role that evaluates theEXISTSsubquery,[66] so grant the ownerSELECTonPLAINTEXT_GRANTStoo and test as an unprivileged role. - CI re-applies the bundle on every push, but an attached policy cannot be replaced (“cannot be dropped/replaced as it is
associated with one or more entities”).[31][68] So bodies change in place with
SET BODY, the tag binding usesFORCE, and a guard adds the row policy once.
3. The pipeline. Pull requests lint manifests and render SQL; apply runs only from main, in a protected environment
whose OIDC token the service user’s WORKLOAD_IDENTITY trusts.[69][63]
Example status: syntax follows Snowflake’s GitHub Actions and workload identity pages; workflow and SQL not run.
-- once, as an administrator: the CI identity has no password and no key
CREATE USER POLICY_CI TYPE = SERVICE DEFAULT_ROLE = DATA_PROTECTION_ADMIN
WORKLOAD_IDENTITY = (TYPE = OIDC
ISSUER = 'https://token.actions.githubusercontent.com'
SUBJECT = 'repo:example-org/data-protection:environment:data-protection-prod');
GRANT ROLE DATA_PROTECTION_ADMIN TO USER POLICY_CI;
# .github/workflows/data-protection.yml
name: data-protection
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: python3 tools/render_bundle.py datasets/ --out build/
# one policy per type; no direct attachments; no tag on row keys
- run: python3 tools/check_bundle.py build/
- uses: actions/upload-artifact@v4
with: { name: bundle, path: build/ }
apply:
if: github.ref == 'refs/heads/main'
needs: plan
runs-on: ubuntu-24.04
environment: data-protection-prod # approvers: security officers (see below)
permissions:
contents: read
id-token: write # lets this job request a GitHub OIDC token
steps:
- uses: actions/checkout@v4 # as in Snowflake's example (config.toml)
with: { persist-credentials: false }
- uses: actions/download-artifact@v4
with: { name: bundle, path: build/ }
- uses: snowflakedb/snowflake-actions@v3
with: { use-oidc: true } # exports SNOWFLAKE_TOKEN for this job
- run: for f in build/*.sql build/datasets/*.sql; do snow sql -x -f "$f"; done
env:
SNOWFLAKE_ACCOUNT: ${{ vars.SNOWFLAKE_ACCOUNT }}
Snowflake accepts a token only if its subject matches POLICY_CI’s, and GitHub emits that subject for any job
naming data-protection-prod,[69] so the environment is the credential boundary. Pull-request jobs lack
id-token: write. Without both conditions below, a branch workflow naming the environment, or an admin approving their own
run, gets a token.
- Deployment branches restricted to
main(private repositories: GitHub Pro, Team or Enterprise).[70] - Approval by someone other than the author, with no administrator bypass. Environment required reviewers do this only
with Prevent self-review selected and Allow administrators to bypass configured protection rules cleared, and on
GitHub Free, Pro and Team only in public repositories.[70] A private repository on Team approves at the merge instead:
branch protection on
mainwith required reviews or code owners, Require approval of the most recent reviewable push (else an approval survives later commits) and Do not allow bypassing the above settings.[70]
4. The evidence query. Which reads of an IDENTIFIER column last month had a masking policy enforced? In
policies_referenced, object-level policies hold row access policies; a masking policy’s policyName sits under
columns[].policies[] with policyKind = 'MASKING_POLICY'.[52] The query flattens in that order, as
Snowflake’s own example does,[71] and joins the result to reads from base_objects_accessed by query,
object and column.
Example status: paths follow the documented JSON shape and Snowflake’s masking-policy example; not executed.
WITH reads AS (
SELECT ah.query_id, ah.query_start_time, ah.user_name,
obj.value:objectName::STRING AS object_name,
col.value:columnName::STRING AS column_name
FROM SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY ah,
LATERAL FLATTEN(input => ah.base_objects_accessed) obj,
LATERAL FLATTEN(input => obj.value:columns) col
WHERE ah.query_start_time >= DATEADD('month', -1, CURRENT_TIMESTAMP())
AND obj.value:objectName = 'CRM.PUBLIC.CUSTOMERS'
AND col.value:columnName IN ('EMAIL', 'FULL_NAME')
),
masked AS (
SELECT ah.query_id,
pobj.value:objectName::STRING AS object_name,
pcol.value:columnName::STRING AS column_name,
pol.value:policyName::STRING AS masking_policy
FROM SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY ah,
LATERAL FLATTEN(input => ah.policies_referenced) pobj,
LATERAL FLATTEN(input => pobj.value:columns) pcol,
LATERAL FLATTEN(input => pcol.value:policies) pol
WHERE ah.query_start_time >= DATEADD('month', -1, CURRENT_TIMESTAMP())
AND pol.value:policyKind = 'MASKING_POLICY'
)
SELECT r.query_start_time, r.user_name, r.object_name, r.column_name, m.masking_policy
FROM reads r
LEFT JOIN masked m
ON m.query_id = r.query_id
AND m.object_name = r.object_name
AND m.column_name = r.column_name
ORDER BY r.query_start_time DESC;
A NULL masking_policy means no policy was enforced: an untagged copy, a dropped tag or a type without a policy. An exempt
role’s read still shows the policy name (the policy ran and returned the value), so the plain-text read share counts both: rows
with NULL, plus rows whose user_name holds a role in PLAINTEXT_GRANTS (an upper bound: the view records users,
not active roles). The view lags up to three hours, keeps 365 days,
omits failed queries and truncates oversized records,[52] so a missing read is not proof it did not happen.
Hands-on labs
Link to section “Hands-on labs”Labs 1 and 2 were executed by the author on 2026-10-17; Lab 3 is documented against Snowflake’s reference pages and not executed. Use throwaway environments only.
Lab 1 (beginner): discover and classify a CSV, then review the scanner
Link to section “Lab 1 (beginner): discover and classify a CSV, then review the scanner”Objective. Produce a classification manifest from a file and learn why scanner output needs review. Prerequisites. Python
3.8 or later, no packages. Cost and risk. None; synthetic data. Save the script as lab1_classify.py and a five-row
customers.csv with columns customer_id, full_name, email, phone, birth_date, card_number, region, notes; leave one notes
value empty and put an email address in another. Example status: verified by execution with python3 -I lab1_classify.py customers.csv manifest.json.
#!/usr/bin/env python3
"""Lab 1: find and classify sensitive columns in a CSV, then write a manifest.
Usage: python3 -I lab1_classify.py <file.csv> <manifest.json>
Pattern-based: a starting point for human review, not a guarantee.
"""
import csv
import json
import re
import sys
from collections import Counter
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[a-z]{2,}$", re.I)
EMAIL_ANY = re.compile(r"[^@\s]+@[^@\s]+\.[a-z]{2,}", re.I)
PHONE = re.compile(r"^\+?[0-9][0-9 ()-]{7,}[0-9]$")
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
CARD = re.compile(r"^\d{13,19}$")
NAME_HINT = re.compile(r"(^|_)(first|last|full)?_?name$", re.I)
def luhn_ok(digits):
total, alt = 0, False
for d in reversed(digits):
n = int(d)
if alt:
n *= 2
if n > 9:
n -= 9
total += n
alt = not alt
return total % 10 == 0
def classify_column(name, values):
"""Return (semantic_category, privacy_category, share_of_matching_rows)."""
vals = [v.strip() for v in values if v and v.strip()]
if not vals:
return ("EMPTY", "NONE", 0.0)
n = len(vals)
share = lambda pred: sum(1 for v in vals if pred(v)) / n
checks = [
("EMAIL", "IDENTIFIER", share(lambda v: bool(EMAIL.match(v)))),
("PAYMENT_CARD", "IDENTIFIER",
share(lambda v: bool(CARD.match(v)) and luhn_ok(v))),
("DATE", "QUASI_IDENTIFIER", share(lambda v: bool(DATE.match(v)))),
("PHONE", "IDENTIFIER", share(lambda v: bool(PHONE.match(v)))),
("FREE_TEXT_WITH_EMAIL", "IDENTIFIER",
share(lambda v: bool(EMAIL_ANY.search(v)) and not EMAIL.match(v))),
]
# Priority order matters: 1988-04-12 also matches the loose phone pattern.
for sem, priv, share_ in checks:
if share_ >= 0.8:
return (sem, priv, share_)
best = max(checks, key=lambda c: c[2])
if best[0] == "FREE_TEXT_WITH_EMAIL" and best[2] > 0:
return (best[0], "IDENTIFIER", best[2]) # leaked emails make it sensitive
if NAME_HINT.search(name):
return ("NAME", "IDENTIFIER", 1.0)
return ("UNCLASSIFIED", "NONE", 0.0)
def main(path, out):
with open(path, newline="", encoding="utf-8") as fh:
rows = list(csv.DictReader(fh))
if not rows:
sys.exit("no rows")
manifest = {"source": path, "rows_sampled": len(rows), "columns": {}}
for col in rows[0].keys():
sem, priv, share = classify_column(col, [r[col] for r in rows])
manifest["columns"][col] = {
"semantic_category": sem,
"privacy_category": priv,
"match_share": round(share, 2),
"review_status": "needs_review" if priv != "NONE" else "ok",
}
with open(out, "w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2)
counts = Counter(c["privacy_category"] for c in manifest["columns"].values())
print(f"{len(manifest['columns'])} columns; {dict(counts)}")
for col, c in manifest["columns"].items():
print(f" {col:16} {c['semantic_category']:22} "
f"{c['privacy_category']:16} {c['match_share']}")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2])
Expected output (observed). 8 columns; {'NONE': 2, 'IDENTIFIER': 5, 'QUASI_IDENTIFIER': 1}, with birth_date as DATE / QUASI_IDENTIFIER, card_number as PAYMENT_CARD (Luhn-valid test numbers) and notes as FREE_TEXT_WITH_EMAIL at share 0.25 (one email in four non-empty notes).
Validation. Remove the priority loop and rerun: the author’s first version labelled birth_date as PHONE, because a date
also matches the loose phone pattern. So every IDENTIFIER tag goes through a human. Troubleshooting. UnicodeDecodeError: save the CSV as UTF-8. Teardown. Delete the directory.
Lab 2 (intermediate): row-level security and column masking in PostgreSQL
Link to section “Lab 2 (intermediate): row-level security and column masking in PostgreSQL”Objective. Serve one customers table to two regions, mask email for everyone without a support_pii role, and observe
two documented limits: owners bypass RLS unless forced,[45] and unique constraints leak the existence of hidden
rows.[45] Prerequisites. A disposable PostgreSQL 13 or later (the author used initdb into a temporary directory with
a Unix socket only). Cost and risk. None if disposable; never run on a shared server.
Example status: verified by execution on PostgreSQL 13.13 on 2026-10-17.
-- lab2_rls.sql : run as a superuser or database owner in a disposable database
CREATE ROLE analyst_in NOLOGIN; -- India analysts
CREATE ROLE analyst_uk NOLOGIN; -- UK analysts
CREATE ROLE support_pii NOLOGIN; -- may see contact details in plain text
CREATE ROLE app_owner NOLOGIN; -- owns the tables and is NOT exempt from RLS
CREATE ROLE asha LOGIN; GRANT analyst_in TO asha;
CREATE ROLE ben LOGIN; GRANT analyst_uk TO ben; GRANT support_pii TO ben;
CREATE SCHEMA crm AUTHORIZATION app_owner;
SET ROLE app_owner;
CREATE TABLE crm.customers (
customer_id int PRIMARY KEY,
full_name text NOT NULL,
email text NOT NULL,
region text NOT NULL CHECK (region IN ('IN', 'UK'))
);
INSERT INTO crm.customers VALUES
(1001, 'Asha Example', 'asha@example.com', 'IN'),
(1002, 'Ben Sample', 'ben@example.org', 'UK'),
(1003, 'Dana Test', 'dana@example.com', 'UK');
CREATE TABLE crm.region_grants (role_name text NOT NULL, region text NOT NULL);
INSERT INTO crm.region_grants VALUES ('analyst_in', 'IN'), ('analyst_uk', 'UK');
ALTER TABLE crm.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE crm.customers FORCE ROW LEVEL SECURITY; -- filter the owner too
CREATE POLICY region_read ON crm.customers FOR SELECT TO PUBLIC
USING (EXISTS (SELECT 1 FROM crm.region_grants g
WHERE g.region = customers.region
AND pg_has_role(current_user, g.role_name, 'MEMBER')));
-- Column masking through a view; security_invoker views arrive in PostgreSQL 15,
-- so on 13 the view runs as its owner and FORCE RLS keeps the owner filtered.
CREATE VIEW crm.customers_masked WITH (security_barrier = true) AS
SELECT customer_id, full_name,
CASE WHEN pg_has_role(current_user, 'support_pii', 'MEMBER') THEN email
ELSE regexp_replace(email, '^(.).*@', '\1***@') END AS email,
region
FROM crm.customers;
GRANT USAGE ON SCHEMA crm TO analyst_in, analyst_uk;
GRANT SELECT ON crm.customers_masked TO analyst_in, analyst_uk;
GRANT SELECT ON crm.region_grants TO analyst_in, analyst_uk; -- the policy reads it
GRANT INSERT ON crm.customers TO analyst_in;
CREATE POLICY region_insert ON crm.customers FOR INSERT TO PUBLIC
WITH CHECK (EXISTS (SELECT 1 FROM crm.region_grants g
WHERE g.region = customers.region
AND pg_has_role(current_user, g.role_name, 'MEMBER')));
RESET ROLE;
Then, in the same database:
SET ROLE asha; SELECT * FROM crm.customers_masked ORDER BY 1;
SET ROLE ben; SELECT * FROM crm.customers_masked ORDER BY 1;
SET ROLE ben; SELECT count(*) FROM crm.customers; -- expect: permission denied
SET ROLE app_owner; SELECT count(*) FROM crm.customers; -- expect: 0 (FORCE RLS)
SET ROLE asha;
INSERT INTO crm.customers VALUES (2001, 'New UK', 'n@example.com', 'UK');
INSERT INTO crm.customers VALUES (1002, 'Collision', 'c@example.com', 'IN');
RESET ROLE;
Expected output (observed). asha sees one row, 1001 | Asha Example | a***@example.com | IN; ben sees rows 1002 and 1003
with plain-text emails; ben’s direct SELECT fails with permission denied for table customers; app_owner counts 0 rows;
the UK insert fails with new row violates row-level security policy for table "customers"; the colliding insert fails with
duplicate key value violates unique constraint "customers_pkey", telling asha that a hidden row 1002 exists. Validation.
Drop FORCE and repeat the owner count: it becomes 3, the documented owner bypass.[45] Troubleshooting. permission denied for table region_grants: the policy runs “with the privileges of the user running the query”,[45] so grant SELECT on the
mapping table. Teardown. Drop the database and roles; delete the temporary instance.
Lab 3 (advanced): tag-based masking, row policy and an evidence query in Snowflake
Link to section “Lab 3 (advanced): tag-based masking, row policy and an evidence query in Snowflake”Objective. Apply the reference bundle to a copy of a table, prove enforcement from two roles and produce the evidence.
Prerequisites. A Snowflake trial or test account on Enterprise Edition; ACCOUNTADMIN to create the policy role.
Cost and risk. Trial credits; classification uses serverless credits.[15] Example status: documented, not executed.
- Create roles
DATA_PROTECTION_ADMIN,SUPPORT_PII,ANALYST_IN,CRM_GLOBAL_READER; grant the firstAPPLY MASKING POLICY,APPLY ROW ACCESS POLICYandAPPLY TAGon the account. - Create
GOV.POLICY; run the reference bundle against a copy ofCRM.PUBLIC.CUSTOMERSloaded with the Lab 1 CSV. - Insert
('ANALYST_IN', 'IN')intoGOV.POLICY.REGION_GRANTS. GrantUSAGEon a warehouse, the database and the schema, andSELECTon the table, toANALYST_IN,SUPPORT_PIIandCRM_GLOBAL_READER. Query asANALYST_IN, then as a test role granted bothSUPPORT_PIIandCRM_GLOBAL_READER. - After access history catches up (up to three hours[52]), run the evidence query.
Expected output. ANALYST_IN sees only IN rows, EMAIL and FULL_NAME as *** and BIRTH_DATE truncated to 1 January;
the privileged role sees everything; the evidence query shows MASK_CLASSIFIED_STRING beside both roles’ reads of EMAIL.
Validation. Set the SUPPORT_PII row’s EXPIRES to yesterday: that role sees *** again with no policy change. Attach
another masking policy directly to EMAIL: it wins, the drift CI must catch.[33] Troubleshooting. An unmasked
NUMBER column means the tag has no policy for that type;[33] ADD ROW ACCESS POLICY … ON (REGION) fails if REGION
carries the classification tag (unset it).[34] Teardown. Drop the database and roles.
Use cases and scenarios
Link to section “Use cases and scenarios”| Scenario | Context | Method | Trade-offs | Success measure |
|---|---|---|---|---|
| Regional analytics on one table (illustrative) | Analysts in two countries see only their own customers | Row access policy on REGION with a central mapping table; identifiers masked for all; global reader by exception |
Mapping lookups add latency; BigQuery RLS forgoes partition pruning[57] | Zero cross-region reads in access history; p50 lead time under a day |
| Sharing with a partner (illustrative) | A supplier needs daily order data without exports | Secure view scoped by CURRENT_ACCOUNT() in a share: read-only, nothing copied;[49][50] Delta Sharing for cross-platform consumers[51] |
Region limits; secure views are slower; consumer logs are theirs | No file exports; share objects under policy |
| Credential-based theft (sourced) | 2024 campaign against Snowflake customer accounts without MFA or allow lists[6] | MFA for humans, network policies, rotation, unload parameters,[28] alerts on stage and COPY INTO activity |
Service users need a method without a password, such as workload identity federation;[24][63] the phase-out runs to late 2026 and excludes reader and trial accounts[24] | No password-only human sessions; unload activity alerted |
| De-identified research release (illustrative) | A hospital shares outcomes with a university | Remove Safe Harbor identifiers (dates to year, three-digit ZIP rules, ages over 89 aggregated),[41] generalise to k ≥ 5,[16] document residual risk per SP 800-188[22] | Utility loss; Expert Determination where Safe Harbor destroys the analysis | Signed risk statement; re-identification test fails |
Measure, govern, pay
Link to section “Measure, govern, pay”Metrics and KPIs
Link to section “Metrics and KPIs”Count exposure and friction, not policies; targets are starting points.
| KPI | Formula | Data source | Pitfall |
|---|---|---|---|
| Sensitive-column coverage | Protected sensitive columns ÷ classified sensitive columns | Classification tags joined to POLICY_REFERENCES[31] |
Unclassified columns are invisible to the ratio; report classification backlog beside it |
| Plain-text read share | Queries reading an IDENTIFIER column unmasked ÷ queries touching any IDENTIFIER column | ACCESS_HISTORY.policies_referenced (reads with no masking policy, plus reads by exempt roles)[52]; BigQuery TableDataRead[53] |
An exempt role’s read still references the policy; the exempt part counts users, not active roles (an upper bound); failed queries are missing and oversized records truncated[52] |
| Over-exposure ratio | Principals with plain-text access to IDENTIFIER columns ÷ principals with any read access | Grants and role membership | Service accounts hide behind one principal |
| Dormant-grant ratio | Grants with no read in 90 days ÷ all grants | Grants joined to access history | Seasonal users; a quarter is the minimum window |
| Request lead time | p50 and p90 of (grant time − request time), per path | Ticketing plus grant timestamps | Averages hide the exception path |
Worked example (illustrative). 1,240 columns are classified IDENTIFIER or SENSITIVE and 1,085 carry a masking or deny policy: coverage = 1,085 ÷ 1,240 = 87.5%, 155 gaps to triage. Last month 31,000 queries touched an IDENTIFIER column; 4,200 referenced no masking policy and 1,100 came from users holding an exempt role: plain-text read share = 5,300 ÷ 31,000 = 17.1%, of which 13.5 points had no policy at all. Of 940 principals with read access, 212 hold a plain-text role: over-exposure = 22.6%. Of 1,900 grants, 310 show no read in 90 days: dormant = 16.3%. Pair every goal with lead time, so that the cheap way to cut exposure (deny everyone) shows up as a failure.
Quality, security, privacy and compliance
Link to section “Quality, security, privacy and compliance”- Covert channels are documented, not theoretical. PostgreSQL states that referential-integrity checks “always bypass row security” and warns of “covert channel” leaks;[45] Lab 2 reproduced it. BigQuery notes that “Side channels, such as the query duration, can leak information about rows that are at the edge of a storage shard”.[57] Where existence itself is sensitive, separate the tables.
- Writes are not reads. Lake Formation filters apply only to reads;[59] Snowflake row policies do not block inserts.[34] Pair read policies with write grants that are at least as narrow.
- Copies escape policies.
CREATE OR REPLACE TABLEandWRITE_TRUNCATEdrop BigQuery policy tags;[42] materialized views conflict with Snowflake policies;[33] time travel and clones are unsupported with Unity Catalog filters.[58] Scan derived objects and propagate classification along lineage.[27] - Superusers are outside the model. pgAudit says “It is not possible to reliably audit superusers”;[55] Postgres
superusers and
BYPASSRLSroles always bypass RLS.[45] Keep them few, break-glass only (AccessOps). - Pseudonymised data: personal for you, not always for the recipient. The EDPB’s consultation draft treats such data as personal and says this “also holds true if pseudonymised data and additional information are not in the hands of the same person”.[20] The CJEU held in September 2025 that pseudonymised data “must not be regarded as constituting, in all cases and for every person, personal data”: a recipient that cannot lift the pseudonymisation or re-identify by cross-checking may hold non-personal data, while the key holder still holds personal data and its information duties are judged from its own position.[21] The case applied the EU institutions’ regulation, which the Court reads like GDPR Art. 4(1). Design the domain so partners and researchers genuinely cannot re-identify; PrivacyOps makes the legal call.
Cost, capacity and sustainability
Link to section “Cost, capacity and sustainability”Policies cost compute at query time: Databricks advises fewer expressions, CASE over mapping tables and SQL over Python UDFs
because each distinct mask is evaluated per query;[58] Snowflake warns that mapping-table lookups slow queries;[34]
BigQuery row policies disable partition pruning, so filtered queries can bill more bytes.[57] Classification costs serverless
credits and, in AI mode, tokens.[15] Edition tiers are the largest line: Snowflake’s policies, classification and access history
need Enterprise,[31] Vault’s transformations the Enterprise ADP module.[18]
Report the spend beside the plain-text read share it buys down; FinOps owns allocation.
Assurance of the operating model
Link to section “Assurance of the operating model”Three monthly checks: drift (the platform’s policy catalogue equals the repository); proof (a canary read appears in access history with the expected policy within the platform’s latency); reach (no new public exposure: Macie policy findings for buckets that become public,[29] S3 Block Public Access status[48]). Record all three; they are the audit evidence.
When things go wrong
Link to section “When things go wrong”Common failures and troubleshooting
Link to section “Common failures and troubleshooting”| Symptom | Likely cause | Diagnosis | Remediation | Prevention |
|---|---|---|---|---|
| Analyst sees plain-text identifiers | Role in session is exempt; direct policy overrides tag; untagged copy | Check policies_referenced, POLICY_REFERENCES, lineage |
Remove exemption or direct policy; tag the copy | CI check for direct attachments; scan derived objects |
| Policy “has no effect” on a column | Data type has no policy on the tag[33] | Compare column type with tag’s policies | Add the policy for that type | Manifest lists type per column |
| Queries slow after policy rollout | Mapping-table lookups or complex UDFs[34][58] | Compare plans with and without policy | Simplify to CASE; centralise mapping table |
Performance test in non-prod |
| Users lose all rows after adding RLS | Default deny once enabled;[45] BigQuery denies unlisted principals[57] | Who is in the grantee list? | Add TRUE filter or permissive policy for intended users |
Roll out with a canary role |
Worked runbook: a masked column shows plain text in a dashboard
Link to section “Worked runbook: a masked column shows plain text in a dashboard”Diagram source (Mermaid)
flowchart TB
S["Report: dashboard shows plain-text EMAIL"]:::risk
Q1{"Is the dashboard reading the governed table?"}:::decision
C1["Extract or derived table: tag the copy or delete it"]:::core
Q2{"Does access history show a masking policy referenced?"}:::decision
C2["Exempt grant, direct policy overriding the tag, or a policy body that returns raw for this role"]:::core
Q3{"Does the column's data type match a policy on the tag?"}:::decision
C3["Add policy for that type; rerun CI check"]:::core
C4["Tag missing on this column (replace dropped it or never applied): re-apply via CI"]:::core
S --> Q1
Q1 -->|"No"| C1
Q1 -->|"Yes"| Q2
Q2 -->|"No"| Q3
Q2 -->|"Yes"| C2
Q3 -->|"No"| C3
Q3 -->|"Yes"| C4
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
Text equivalent: start from the report. Does the dashboard read the governed table? If not, the source is an extract or derived table: tag or delete the copy. If it does, does access history show a masking policy? If yes, the policy ran and returned plain text: an exemption, a direct policy overriding the tag policy, or a body that returns raw for that role. If not, does the column’s data type match a policy on the tag? If not, add one and rerun the CI check; if it does, the tag is missing (a replace dropped it or it was never applied): re-apply it through CI.
Runbook (illustrative incident). 09:10 a steward reports plain-text emails in a sales dashboard. 09:15 the dashboard reads
CRM.PUBLIC.CUSTOMERS_DAILY, rebuilt nightly with CREATE OR REPLACE and in no manifest. 09:25 access history shows the
dashboard’s service user reading CUSTOMERS_DAILY.EMAIL with no policy. 09:30 containment: revoke the dashboard role’s SELECT
on the table. 09:40 fix: add the table to the manifest; CI applies the tags; the nightly job now overwrites rows instead of
replacing the table, so tags survive. 09:50 verification as the dashboard role: ***. Follow-ups: scan all %_DAILY tables;
derived tables inherit source tags; the privacy officer gets the plain-text read count and decides whether it is reportable.
Anti-patterns and myths
Link to section “Anti-patterns and myths”- “We encrypt at rest, so the data is protected.” Snowflake encrypts everything and rotates keys by default;[25] that did nothing against the valid credentials used in 2024.[6]
- “Masking equals de-identification.” NIST SP 800-188: tools that merely mask may lack “sufficient functionality for performing de-identification”.[22] Masking protects a display; de-identification manages a release.
- “Hash the email and it is anonymous.” A deterministic hash is joinable and guessable from an address list; BigQuery offers it precisely so joins still work.[61] It is pseudonymisation at best.
- Policy in the console: fast, unreviewed, discovered during an incident. One global reader role “for the pipeline”: every job, and whatever compromises it, inherits the widest access. A review that nobody can fail: reviews that never revoke are evidence of attention, not control. Buying a platform before classifying: it automates a decision you have not made.
Deciding and adopting
Link to section “Deciding and adopting”When to use and when not to use
Link to section “When to use and when not to use”Use it when several teams query shared data with personal, financial or regulated fields; when access takes days or is granted by chat; when test environments are copied from production; when data is shared externally; or when an audit asks who read what. Do not start here when one application owns its database and nobody else queries it (application authorisation and DBOps suffice); when the data is public or synthetic; or when identity basics are missing (no central groups, no MFA): fix those with IAMOps first, because row policies on top of shared passwords protect nothing. Exit condition: standard access is boring and exceptions are rare, dated and reviewed.
Decision framework
Link to section “Decision framework”Diagram source (Mermaid)
flowchart TB S["Sensitive field?"]:::decision N["No field control; record as INTERNAL"]:::neutral A["Reversible?"]:::decision B["Keep format?"]:::decision C["FPE"]:::core D["Tokenise (vault)"]:::core E["Joins needed?"]:::decision F["Deterministic hash or keyed HMAC"]:::core G["Leaves the org?"]:::decision H["De-identify"]:::risk I["Mask or deny"]:::core S -->|"No"| N S -->|"Yes"| A A -->|"Yes"| B B -->|"Yes"| C B -->|"No"| D A -->|"No"| E E -->|"Yes"| F E -->|"No"| G G -->|"Yes"| H G -->|"No"| I classDef core fill:#e8f3f2,stroke:#16756f,color:#122029 classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029 classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029 classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
Text equivalent: a field that is not sensitive needs no field-level control; record it as INTERNAL. For a sensitive field, if a workload must get the real value back (reversible), choose format-preserving encryption when the stored format must stay the same, otherwise vault tokenisation. If nothing needs it back and the field is needed for joins, use a deterministic hash or keyed HMAC, still personal data for your organisation. If it is not needed for joins and the data leaves the organisation, de-identify with a residual-risk sign-off; if it stays inside, dynamic masking or a column deny at query time is enough.
Platform-native, overlay or proxy? Score on the number of platforms (one → native), the need for one workflow across platforms (overlay), tolerance for a component in the query path (proxy: latency, outage mode), where evidence must live (your logs → push-down) and budget. One warehouse rarely needs an overlay; three platforms almost always want a compiler of policy, bought or written.
Maturity model
Link to section “Maturity model”This Atlas’s model, not an industry standard; CISA’s zero-trust stages[38] are an external vocabulary.
| Level | Observable behaviour | Exit criteria |
|---|---|---|
| 1 Ad hoc | Grants by chat; no classification; test data copied from production | Written classification standard; inventory of sensitive tables |
| 2 Classified | Discovery runs; tags on top tables; masking on a few columns | Coverage above 50% on tier-1 tables; MFA for all humans |
| 3 Enforced | Tag-driven policies; policy in Git with CI; request workflow measured | Coverage above 90%; no console policies; no plain-text identifiers in non-production |
| 4 Evidenced | Access-history reports; reviews that revoke; drift and canary checks | Plain-text reads and dormant grants falling for two quarters; evidence from queries, not screenshots |
| 5 Adaptive | Lineage propagates tags; exceptions expire automatically; agents and shares on the same path; releases with measured risk | New sensitive table protected before its first grant, every time |
Adoption roadmap: 30/60/90 days
Link to section “Adoption roadmap: 30/60/90 days”- Days 1–30. Classification standard; discovery on the main warehouse; top 50 tables triaged with owners; MFA, network policies and unload controls; policy repository with tags and one masking policy per type; baseline metrics.
- Days 31–60. Tag-based policies on tier-1 tables, non-production then production; request workflow live; unexplained broad grants revoked in two waves; drift check in CI; first access-history report.
- Days 61–90. First quarterly review with revocations; masked non-production rebuild; shares and service users on the governed path; canary and reach checks scheduled; scorecard and next targets published.
Toolkit
Link to section “Toolkit”Checklists
Link to section “Checklists”Quick start
- Classification standard written (levels, default handling per level, who may change a tag)
- Discovery run on the primary platform; findings triaged with owners
- MFA enforced for human users; network policy for the platform; no service user signs in with a password
- Policy repository created; CI applies with a dedicated policy-owner role
Preflight for a new sensitive dataset
- Owner and steward named; manifest written; classification confirmed by a human
- A policy exists for every tagged column’s data type
- Acceptance queries pass as an unprivileged role and as an exempt role in non-production
- Derived tables, views and extracts identified and tagged
Quarterly review
- Dormant grants listed and revoked or re-justified
- Exceptions past expiry closed
- Plain-text read share reported with the top ten principals
- Drift check: platform policy catalogue equals repository
Templates
Link to section “Templates”Classification standard (one page). Template status: text.
Level Examples Default handling
INTERNAL ids, region, product codes readable by platform roles
QUASI_IDENTIFIER birth date, postcode, job title year or area only, unless granted
IDENTIFIER name, email, phone, national id masked; plain text by exception
SENSITIVE health, salary, credentials denied; row-scoped grants only
Rules: CI sets tags from manifests; a human confirms every IDENTIFIER and SENSITIVE
tag; exceptions carry ticket, approver and expiry (max 180 days); reviews quarterly.
Access request intake. Template status: text.
Requester / team: Manager:
Dataset(s) and columns: Purpose (one sentence):
Path: [ ] masked standard [ ] plain-text exception [ ] export/share
Duration requested (max 180 days): Row scope (region/BU):
Owner approval (name, date): Privacy sign-off (exceptions only):
Grant applied by (PR link): Expiry date:
Policy change record (ADR-style). Template status: text.
Title: Mask BIRTH_DATE to year for all non-support roles
Context: quasi-identifier; analysts need age bands only
Decision: tag QUASI_IDENTIFIER -> MASK_CLASSIFIED_DATE branch (DATE_TRUNC year)
Alternatives rejected: nullify (breaks cohort analysis); plain text (over-exposure)
Consequences: age-at-event derived in a view; SUPPORT_PII exempt until 2026-12-31
Evidence: PR #214; POLICY_REFERENCES diff; canary query id
Deep comparisons
Link to section “Deep comparisons”| Technique | Reversible? | Joins preserved? | Personal data for the holder? | Typical use |
|---|---|---|---|---|
| Display masking (partial, null, default) | No (display only) | No | Yes (source unchanged) | Analyst views, dashboards |
| Deterministic hash | No (but guessable) | Yes | Yes | Joins across tables without the raw value[61] |
| Tokenisation | Yes, via vault | Only if convergent[72] | Yes (vault exists) | Keep identifiers out of the platform[18] |
| Format-preserving encryption | Yes, via key | Yes | Yes | Legacy schemas, fixed-format fields[18] |
| Pseudonymisation (domain-designed) | Depends on key custody | Yes | Yes for the key holder; possibly not for a recipient without it[21] | Internal analytics, partner and research sharing |
| De-identification (k-anonymity, DP) | No | Limited | Only while re-identification remains reasonably likely[21][22] | External releases |
Is DataSecOps a recognised discipline? No. It is a vendor-coined label (Satori, 2021; part of Commvault since 2025)[1][3] for a practice usually called data security governance. Cite standards, not the label, in policy.
Do I need a data security platform? Not to start: every major engine enforces row and column policy natively, and Git plus CI gives policy as code. Buy an overlay when several platforms need one workflow and one policy language.
Is masking enough for GDPR? Masking is a security measure; GDPR Art. 32 lists pseudonymisation and encryption “as appropriate”,[40] and the EDPB says pseudonymisation “alone will normally not be a sufficient measure”.[20] Lawful basis, minimisation and rights handling are separate obligations (PrivacyOps).
Can I rely on the scanner’s classification? No. Presidio warns “there is no guarantee that Presidio will find all sensitive information”,[47] and Google makes the same caveat.[32] Scanners find candidates; humans confirm IDENTIFIER and SENSITIVE tags.
Does a proxy overlay replace native policies? No. A proxy enforces only on traffic that passes through it; a direct connection, a share, a clone or an unload bypasses it. Keep native policies as the floor.
Who should own the policy repository? Data security engineering, with data owners as required reviewers for their datasets and the security or privacy officer approving exemptions. Platform teams run the pipeline, not the decisions.
Going further
Link to section “Going further”Advanced topics and emerging direction
Link to section “Advanced topics and emerging direction”- One policy model across platforms (open problem): tag-based policy exists everywhere (Snowflake tags, Unity Catalog ABAC with metastore-level and DENY policies still Beta,[43] LF-Tags[44]), each in its own dialect.
- Contracts and lineage as tag sources (emerging): ODCS v3.2.0 carries
classificationandencryptedNameper property,[64] and OpenLineage’s column lineage facet records whether a transformation masked its input,[27] so producers can declare tags and pipelines inherit them. - Differential privacy for releases (established mathematics, early tooling): NIST SP 800-226 explains how to evaluate claimed guarantees;[37] expect it in query interfaces for external analysis, not everyday BI.
- Agents as consumers (emerging): per-agent identities exist (Snowflake’s
SERVICE_AGENTusers, logged asEXTERNAL_AGENT[52]); purpose-bound grants for agents are still speculative.
Learning path
Link to section “Learning path”- Prerequisites. SQL; role-based access in one platform; the basics of DataOps and IAMOps.
- One day. Run Lab 1; read your platform’s masking and row-policy introductions.[31][34][42][57]
- One week. Run Lab 2; write a manifest for a real table; attach a tag policy in non-production; produce one access report.
- Thirty days. Deliver the first 30 days of the roadmap on one platform; read NIST SP 800-188,[22] the EDPB guidelines[20] and the 2025 CJEU ruling;[21] design a pseudonymisation domain for one dataset.
- Specialisation. De-identification and differential privacy;[37] multi-platform policy compilation. Portfolio project: a policy repository with CI, a drift check and a scorecard for a sample warehouse.
Wrap-up
Link to section “Wrap-up”Executive recap and cheat sheet
Link to section “Executive recap and cheat sheet”- Decide: access is enforced at the platform, by row and column; policy lives in version control.
- Do first: MFA and network policy; classify tier-1 tables; tag-based masking; unload controls; a measured request path.
- Measure: coverage, plain-text read share, over-exposure, dormant grants, request lead time.
- Tools: native policies first; scanners for candidates; a vault for tokenisation; an overlay only for several platforms.
- Watch for: copies that drop tags, direct policies overriding tags, covert channels, superusers, reviews that never revoke, agents with job roles.
A judgement call: mask at query time or tokenise at ingestion?
Link to section “A judgement call: mask at query time or tokenise at ingestion?”Situation (illustrative). A subscription business lands customer records (name, email, phone, national ID, plan, usage) in a
warehouse used by 60 analysts and a dozen pipelines. Option A: land everything in plain text and rely on dynamic masking with a
SUPPORT_PII exemption. Option B: tokenise email, phone and national ID before landing (vault-backed, reversible only by a
support service) and mask names dynamically. Assumptions. Analytics never needs raw contact fields; support needs about 200
lookups a day; the edition supports masking; a highly available vault is feasible.
Reasoning. (1) Worst failure. Under A, a stolen analyst credential, an exempt role or an untagged copy exposes real identifiers, and credential theft is the common path.[6] Under B the warehouse never holds them; the worst case is tokens plus names, which identify but do not enable contact or fraud. (2) Pseudonymisation domain. B keeps the re-identification key outside the analytics domain, the EDPB’s design intent;[20] A has no domain boundary. The tokens stay personal data for the company, which runs the vault;[21] B cuts risk, not legal duties. (3) Cost of B. A vault in the ingestion path, an availability dependency and an Enterprise module;[18] joins survive only if the tokenisation is convergent, which Vault says to enable only if needed.[72] (4) What would change the answer. If analysts genuinely needed raw contact fields, B would make the exception path the normal path, and A with strict exemptions would be more honest.
Decision: B for the three contact identifiers, A for the rest. A everywhere is attractive and wrong, because it puts every control downstream of a credential. Uncertainty: the real exception rate is unknown; run B for a quarter and count requests for raw contact data before extending it.
Learning objectives and mastery check
Link to section “Learning objectives and mastery check”Objectives: explain the label’s status; choose a technique per field; implement tag-driven policy on one platform; produce access-history evidence; diagnose a leak; argue the ingestion-versus-query-time trade-off.
1. A colleague says “we are GDPR-safe because emails are hashed”. What is wrong, and what would you check?
A deterministic hash is pseudonymisation at best, and for the organisation that holds the original emails the hashed data remain personal data; the 2025 CJEU ruling helps only a recipient that cannot re-identify.[19][21] Check who can map hashes back (anyone with an address list), whether the hash is salted per query or joinable,[61] and whether lawful basis and minimisation were addressed at all.
2. A row access policy is attached, yet the data owner still sees every row in PostgreSQL. Why, and how do you fix it?
Table owners bypass row security unless the table is set to FORCE ROW LEVEL SECURITY; superusers and BYPASSRLS roles always
bypass.[45] Force it; keep the owner role out of daily use.
3. A tagged NUMBER column with a customer ID is returned unmasked while tagged STRING columns are masked. Diagnose.
A tag holds one masking policy per data type; the tag has no policy for NUMBER, so the column is unprotected.[33] Add
the policy and make CI check that every tagged type has one.
4. Plain-text read share: 12,400 queries touched IDENTIFIER columns; 930 had no masking policy, 310 came from users holding exempt roles.
(930 + 310) ÷ 12,400 = 10.0%: 7.5 points unprotected, 2.5 points approved exceptions. Report both parts with the top principals. The exception part is an upper bound (the view records users, not active roles); truncated records can hide unprotected reads.[52]
5. The hospital wants to share patient outcomes with a university by removing names and medical record numbers. Enough?
Conclusion
Link to section “Conclusion”DataSecOps is a borrowed name for an unglamorous loop: find the sensitive data, say who may see which rows and columns, make the platform enforce it, read the logs, revoke what is no longer needed. The name belongs to a vendor; the loop belongs to anyone who runs a shared data platform. The mechanisms are native and cheap, so the hard part is discipline: classification confirmed by humans, policy in version control, evidence from queries, identity hygiene treated as data protection. The incidents on this page were not clever. The practice that would have stopped them is not clever either; it is simply continuous.
Sources and further reading
Link to section “Sources and further reading”All sources opened on 2026-10-17; platform documentation is live and carries no publication date unless shown. Ledger:
research/datasecops-ledger.md.
Changelog: 2026-10-17 — first version (research, draft, Labs 1 and 2 executed, validator and browser QA by the author).
Changelog: 2026-10-17 — round-1 revision after independent review (reviews/datasecops-r1.md): policy bundle rebuilt around
tag-bound (not tag-value) masking with SYSTEM$GET_TAG_ON_CURRENT_COLUMN, dataset-scoped dated exemptions and the row-policy /
masking-policy exclusivity (F1, F3); evidence query re-written to the documented policies_referenced shape (F2); egress/DLP
paragraph, two FAQ entries, pipeline preconditions, runbook and decision-tree corrections, wording fixes (F4–F15); ~1,000 words
trimmed. Three sources added.
Changelog: 2026-10-17 — round-2 revision after independent scoring (reviews/datasecops-score.md): pseudonymisation qualified
by CJEU C-413/23 P (N1); EXTERNAL_AGENT agents (N2); pipeline on workload identity federation with checkout (N3, N4);
Figures 2 and 3 redrawn, maturity figure dropped (N5); code wrapped (N6); exempt reads in KPI examples (N7); Lab 1 data (N8);
Herzberg paraphrase (N9); tokens join only if convergent; decision-tree text equivalent qualified (N1). Five sources added.
Changelog: 2026-10-17, post-score polish (reviews/datasecops-polish.md): re-runnable policy bundle (S1); GitHub plan tier and
Team fallback (S2); Lab 3 grants and checklist (S3); exempt reads as an upper bound (S4); not-sensitive exit in the technique
tree; repetition trimmed; two sources added. Polish check: GitHub settings named (P1, P2).
What is DataSecOps and why it matters — Help Net Security (podcast transcript with Ben Herzberg, Satori), 2021-08-04. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
DataSecOps — Satori Cyber (vendor page, undated). Checked 2026-10-17. ↩︎ ↩︎
Form 10-Q for the quarter ended September 30, 2025 — Commvault Systems, Inc., SEC EDGAR, 2025. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎
Gartner Report: Market Guide for Data Security Platforms — Gartner (J. Fritsch, B. Lowans), 2025-03-26, vendor-hosted reprint on Thales. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Data security posture management (DSPM) — Wiz Academy (vendor, undated). Checked 2026-10-17. ↩︎
UNC5537 Targets Snowflake Customer Instances for Data Theft and Extortion — Mandiant / Google Cloud, 2024-06-10. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Information on the Capital One cyber incident — Capital One, 2019. Checked 2026-10-17. ↩︎
OCC Assesses $80 Million Civil Money Penalty Against Capital One — Office of the Comptroller of the Currency, 2020-08-06. Checked 2026-10-17. ↩︎
Cost of a Data Breach Report 2026 — IBM and Ponemon Institute, 2026. Checked 2026-10-17. ↩︎
DataOps vs DataSecOps — Satori Cyber (vendor page, undated). Checked 2026-10-17. ↩︎
PostgreSQL 9.5 release notes — PostgreSQL Global Development Group, 2016-01-07. Checked 2026-10-17. ↩︎
Row-level filtering and column-masking using Apache Ranger policies in Apache Hive — Apache Ranger wiki. Checked 2026-10-17. ↩︎
What is DataSecOps? — NextLabs community blog (vendor, undated). Checked 2026-10-17. ↩︎
DataSecOps: Segurança em Ambientes Híbridos no Itaú — IBSEC (Portuguese), 2026-09-26. Checked 2026-10-17. ↩︎
Sensitive data classification — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
k-anonymity: a model for protecting privacy — Latanya Sweeney, International Journal on Uncertainty, Fuzziness and Knowledge-based Systems 10(5), 2002. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
What is the difference between masking and truncation? — PCI Security Standards Council FAQ. Checked 2026-10-17. ↩︎ ↩︎
Transform secrets engine — HashiCorp Vault Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Art. 4 GDPR — Definitions — Regulation (EU) 2016/679, unofficial consolidated text (Intersoft Consulting). Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Guidelines 01/2025 on Pseudonymisation (version for public consultation, PDF) — European Data Protection Board, adopted 2025-01-16; the consultation page shows “Closed for feedback” and links no final version. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Judgment in Case C-413/23 P, EDPS v SRB — Court of Justice of the European Union (First Chamber), 2025-09-04, ECLI:EU:C:2025:645 (paras 52, 76–87, 111). Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
SP 800-188: De-Identifying Government Datasets: Techniques and Governance — NIST, 2023-09. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
SP 800-207: Zero Trust Architecture — NIST, 2020-08. Checked 2026-10-17. ↩︎ ↩︎
Planning for the deprecation of single-factor password sign-ins — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Understanding encryption key management in Snowflake — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
AWS KMS keys (concepts) — AWS Key Management Service Developer Guide. Checked 2026-10-17. ↩︎ ↩︎
Column level lineage dataset facet — OpenLineage specification. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Parameters — Snowflake Documentation (
PREVENT_UNLOAD_*,REQUIRE_STORAGE_INTEGRATION_*andDISABLE_UI_DOWNLOAD_BUTTONentries). Checked 2026-10-17. ↩︎ ↩︎ ↩︎What is Amazon Macie? — Amazon Macie User Guide. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎
Learn about data loss prevention — Microsoft Learn, updated 2026-06-26. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Dynamic Data Masking — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Data profiles — Google Cloud Sensitive Data Protection documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Tag-based masking policies — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Understanding row access policies — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
SP 800-53 Rev. 5: Security and Privacy Controls for Information Systems and Organizations — NIST, 2020 (Release 5.2.0, 2025-08-27). Checked 2026-10-17. ↩︎
The NIST Cybersecurity Framework (CSF) 2.0 — NIST, 2024-02-26. Checked 2026-10-17. ↩︎
SP 800-226: Guidelines for Evaluating Differential Privacy Guarantees — NIST, 2025-03. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Zero Trust Maturity Model — CISA, Version 2.0 (2023). Checked 2026-10-17. ↩︎ ↩︎
Art. 25 GDPR — Data protection by design and by default — Regulation (EU) 2016/679, unofficial consolidated text. Checked 2026-10-17. ↩︎
Art. 32 GDPR — Security of processing — Regulation (EU) 2016/679, unofficial consolidated text. Checked 2026-10-17. ↩︎ ↩︎
45 CFR § 164.514 — Other requirements relating to uses and disclosures of protected health information — Legal Information Institute, Cornell (eCFR mirror). Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Introduction to column-level access control — Google Cloud BigQuery documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎
Attribute-based access control (ABAC) — Databricks Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Lake Formation tag-based access control — AWS Lake Formation Developer Guide. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Row Security Policies — PostgreSQL documentation (current). Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Apache Ranger — Apache Software Foundation. Checked 2026-10-17. ↩︎
Presidio documentation — Data Privacy Stack (formerly Microsoft). Checked 2026-10-17. ↩︎ ↩︎
Blocking public access to your Amazon S3 storage — Amazon S3 User Guide. Checked 2026-10-17. ↩︎ ↩︎
Introduction to Secure Data Sharing — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎
Working with secure views — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎
Delta Sharing — Delta Lake project (Linux Foundation). Checked 2026-10-17. ↩︎ ↩︎
ACCESS_HISTORY view — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
BigQuery audit logs overview — Google Cloud BigQuery documentation. Checked 2026-10-17. ↩︎ ↩︎
Audit log system table reference — Databricks Documentation. Checked 2026-10-17. ↩︎
pgAudit: PostgreSQL Audit Extension (README) — pgaudit project on GitHub. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
OpenLineage documentation — OpenLineage project (LF AI & Data). Checked 2026-10-17. ↩︎
Introduction to row-level security — Google Cloud BigQuery documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Row filters and column masks — Databricks Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Data filtering and cell-level security in Lake Formation — AWS Lake Formation Developer Guide. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎
CREATE POLICY — PostgreSQL documentation (current). Checked 2026-10-17. ↩︎
Introduction to data masking — Google Cloud BigQuery documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎
CREATE USER — Snowflake Documentation (
TYPEvalues, includingSERVICE_AGENT). Checked 2026-10-17. ↩︎Workload identity federation — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Open Data Contract Standard v3.2.0 — schema and release v3.2.0 — Bitol (LF AI & Data), released 2026-09-08 (the release page shows “08 Sep” without a year; the date was read from the GitHub releases API,
published_at2026-09-08T16:13:35Z). Checked 2026-10-17. ↩︎ ↩︎SYSTEM$GET_TAG_ON_CURRENT_COLUMN and SYSTEM$GET_TAG_ON_CURRENT_TABLE — Snowflake Documentation. Checked 2026-10-17. ↩︎
CREATE MASKING POLICY — Snowflake Documentation. Checked 2026-10-17. ↩︎ ↩︎
CREATE ROW ACCESS POLICY — Snowflake Documentation. Checked 2026-10-17. ↩︎
ALTER MASKING POLICY, ALTER ROW ACCESS POLICY, ALTER TAG, ALTER TABLE, POLICY_REFERENCES and Working with variables — Snowflake Documentation. Checked 2026-10-17. ↩︎
Snowflake CLI GitHub Action — Snowflake Documentation (OIDC setup and subject formats). Checked 2026-10-17. ↩︎ ↩︎
Deployments and environments, Managing environments for deployment, About protected branches and GitHub’s plans — GitHub Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Access History (user guide, policy query examples) — Snowflake Documentation. Checked 2026-10-17. ↩︎
Tokenization transform — HashiCorp Vault Documentation (convergent tokenization). Checked 2026-10-17. ↩︎ ↩︎