---
title: "DataSecOps"
slug: datasecops
category: security
term_status: vendor-specific
evidence_status: verified
summary: "DataSecOps is a vendor-promoted label for building security into data platform operations: classifying data, enforcing who may see which rows and columns, masking or tokenising sensitive fields, and proving it from access logs."
audience: [Data platform engineers, Data security architects, Data stewards and owners, Security and privacy leads, Beginners]
last_verified: 2026-10-17
key_takeaways:
  - "The label was defined in 2021 by Satori Cyber's chief scientist and is repeated mostly by vendors; Satori joined Commvault in 2025. No standards body uses the term: it names data security governance done continuously."
  - "The protected object is business data; the unit of control is the row, the column and the query. That separates this page from SecDataOps (security telemetry), PrivacyOps (legal process) and DataOps (pipeline flow)."
  - "Every major warehouse and lakehouse enforces row and column policies at query time; know each one's edition limits, precedence rules and covert channels before promising a control."
  - "Masking, tokenisation, format-preserving encryption, pseudonymisation and de-identification give different guarantees. Pseudonymised data stay personal for the key holder; since a 2025 EU Court of Justice ruling, not always for a recipient that cannot re-identify anyone."
  - "The 2024 campaign against Snowflake customer instances used stolen passwords on accounts without MFA or allow lists: credential hygiene is a DataSecOps control."
  - "Run it as a loop: discover, classify, policy as code, enforce, control egress, read access history, review and revoke. Measure coverage, plain-text read share and dormant grants, not policy counts."
related: [dataops, secdataops, privacyops, iamops, accessops, complianceops, analyticsops, researchdataops, dbops, cloudsecops]
---

## What DataSecOps is

### 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".[^hns2021] 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

| Item | Summary |
|---|---|
| Term status | **Vendor-specific.** Defined and promoted by Satori Cyber (2021);[^hns2021][^satori-page] part of Commvault since 2025-08-28.[^cvlt-10q] Analysts (Gartner) call the tooling "data security platforms";[^gartner-dsp] vendors also sell "data security posture management";[^wiz-dspm] 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](../dataops/); security telemetry → [SecDataOps](../secdataops/); lawful basis and DSARs → [PrivacyOps](../privacyops/); identities → [IAMOps](../iamops/); privileged access → [AccessOps](../accessops/); database engines → [DBOps](../dbops/); cloud posture → [CloudSecOps](../cloudsecops/). |
| Prerequisites | A working pipeline and catalogue habit ([DataOps](../dataops/)); basic identity and role design ([IAMOps](../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

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.[^unc5537]

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";[^capone] the OCC "assessed an $80 million civil money
penalty".[^occ] That control plane belongs to [CloudSecOps](../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".[^ibm2026] 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

::: note Three names that look alike
- **DataSecOps** (this page) secures *business data*: who may see which rows and columns, and can we prove it.
- **SecDataOps** ([SecDataOps](../secdataops/)) engineers *security data*: logs, telemetry, SIEM and lake tiers for defenders.
- **DevSecOps** ([DevSecOps](../devsecops/)) secures the *software delivery pipeline*.

The first two follow an Atlas mnemonic ("DataSecOps secures data; SecDataOps builds data for security"): an **editorial
distinction**, since neither label is standardised.
:::

It is also not:

- **PrivacyOps.** Both pages discuss masking and minimisation; this page owns the technical controls, [PrivacyOps](../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".[^satori-vs] 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".[^gartner-dsp]
- **Backup, recovery or storage:** [BackupOps](../backupops/), [DisasterRecoveryOps](../disasterrecoveryops/), [StorageOps](../storageops/).

## Where the label comes from and where it sits

### Origins and evolution of the label

The practice predates the name. PostgreSQL added row-level security in 9.5 (January 2016),[^pg95] and Apache Ranger added
row filtering and column masking in release 0.6, enforced by Apache Hive 2.1 and later.[^ranger-hive] 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.[^hns2021]
- **2021–2025.** Satori's site carries the definition and seven principles;[^satori-page] other vendors publish near-identical
  definitions without attribution.[^nextlabs]
- **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.[^cvlt-10q]
- **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);[^ibsec] a search for 2025–2026 material returns little else.

### 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.[^sf-classify] | Sweeney found 87% of the US population likely unique on ZIP, gender and birth date alone.[^sweeney] |
| 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](#quality-security-privacy-and-compliance). |
| 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".[^pci-faq] |
| Tokenisation | Replace a value with an unrelated token held in a vault; "The original sensitive value cannot be recovered from a token alone."[^vault-transform] | 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.[^gdpr4] The EDPB calls the context that must stay isolated the *pseudonymisation domain*.[^edpb] | Personal data for the key holder; a recipient unable to re-identify may not hold personal data (CJEU, 2025).[^cjeu-srb] 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.[^sp800188] | 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

::: figure Where DataSecOps sits among neighbouring Atlas pages
```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](../dataops/) | Pipeline flow, tests, data incidents | DataOps keeps personal data out of test outputs; this page sets the masking rules it applies |
| [SecDataOps](../secdataops/) | Telemetry coverage, parsing, SIEM cost | Data-platform access history is a log *source* for SecDataOps |
| [PrivacyOps](../privacyops/) | Legal process, DSARs, records of processing | PrivacyOps says *what* must be minimised; this page says *how* and proves it |
| [IAMOps](../iamops/) | Identity-centric policy across all systems | Platform roles and groups come from IAMOps; row and column policy stays here |
| [AccessOps](../accessops/) | Just-in-time elevation, session recording | Break-glass to a warehouse is AccessOps; standing analyst access is here |
| [ComplianceOps](../complianceops/) | Frameworks, audits, control testing | Access-review records and policy coverage reports are the evidence |

## Principles and operating model

### Charter and principles

::: decision A proposed charter (this Atlas's proposal, not an industry standard)
**Mission.** Make the fastest path to data the governed path: approved access in hours, enforced at the platform, provable from
logs, and revisited on a schedule.

**Principles.**
1. **The data is the perimeter.** Decide access at the row and column. Zero trust "focuses on protecting resources (assets,
   services, workflows, network accounts, etc.)",[^sp800207] and a column is a resource.
2. **Classify first, protect by tag.** Policies attach to classifications, so a new tagged column is protected before anyone asks.
3. **Policy is code.** Tags and policies live in version control, are tested in non-production and applied by a pipeline that
   can re-run safely (change in place, attach once). The console is for reading.
4. **Prove it, do not assert it.** Every control has a query against access history that shows it working; every review leaves a
   dated record.
5. **Least exposure, not just least privilege.** Prefer masked, filtered or tokenised paths to plain-text grants.
6. **Identity hygiene is data security.** MFA, rotation, network allow lists and scoped service users for the platform are in
   scope, because the 2024 campaign succeeded without them.[^unc5537]
7. **Minimum friction for the common case.** Fast approval for standard requests, deliberate approval for exceptions. Herzberg's
   point: complicated or non-deterministic access decisions either disable the business's use of data or expose security
   risks.[^hns2021]

**Non-goals.** The privacy programme's legal judgements; securing the network, endpoints or the CI/CD pipeline.
:::

### Fundamentals: the building blocks

Nine blocks; the [tooling landscape](#tooling-landscape) names products for each.

1. **Discovery and classification:** scanners propose, humans confirm.
2. **Identity and roles:** groups, MFA, network policies; Snowflake is phasing out password-only sign-ins through 2026.[^sf-mfa]
3. **Fine-grained policy:** row, column and cell policy inherited through tags, enforced at query time.
4. **Value transformation:** masking, hashing, tokenisation, FPE and de-identification, each with different guarantees
   ([deep comparisons](#deep-comparisons)).
5. **Encryption and keys:** Snowflake rotates hierarchy keys older than 30 days;[^sf-encryption] AWS KMS keeps HSM keys from
   leaving in plain text.[^kms]
6. **Storage perimeter and egress** (next paragraph).
7. **Sharing** through read-only shares and secure views.
8. **Audit and lineage:** who read what under which policy; column lineage with a `masking` flag.[^ol-column]
9. **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,[^unc5537] an ordinary unload. Snowflake exposes
the levers as account or user parameters, all defaulting to `FALSE`:[^sf-params]

```text
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[^macie] and
Purview DLP acts on documents, mail and (in preview) Microsoft 365 Copilot.[^purview-dlp] 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](../secdataops/).

### 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,[^sf-ddm] which matches the RACI: owners approve *who*, security defines *how*.

### 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](#adoption-roadmap-306090-days).
- **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

- **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_REFERENCES` in Snowflake[^sf-ddm]) 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](../incidentops/);
  [ResponseOps](../responseops/) if exfiltration is suspected).

## Architecture and workflow

### 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.

::: figure Reference architecture: control plane, enforcing data planes and consumers
```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";[^gcp-sdp] 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,[^sf-tagmask] 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.[^sf-rap]
- *Data planes* enforce reads; writes, views, clones and unloads have limits of their own (see [quality and
  security](#quality-security-privacy-and-compliance)).
- *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

::: figure Happy path and exception path for a data access request
```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

### 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.[^sf-tagmask] (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

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);[^sp80053] NIST CSF 2.0[^csf2] | 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)[^sp800188] | Guidance | De-identification techniques, disclosure review boards, re-identification studies |
| NIST SP 800-226 (2025)[^sp800226] | Guidance | Differential privacy as "a mathematical framework that quantifies privacy loss" |
| NIST SP 800-207 (2020); CISA ZTMM v2.0[^sp800207][^cisa-ztmm] | Framework | Zero trust as protecting resources; stages "traditional, initial, advanced, and optimal" |
| GDPR Art. 4(5), 25, 32[^gdpr4][^gdpr25][^gdpr32] | 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)[^edpb] | 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)[^cjeu-srb] | 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)[^hipaa514] | 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)[^pci-faq] | Contractual (card data) | Display masking versus unreadable storage; "Masking is not synonymous with truncation" |

### 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+);[^sf-ddm] BigQuery;[^bq-cls] Unity Catalog (parts Beta);[^dbx-abac] Lake Formation;[^lf-tbac] PostgreSQL;[^pg-rls] Apache Ranger[^ranger] | No extra vendor; one dialect per platform; know precedence, covert channels and edition tiers |
| Discovery and classification | Snowflake classification;[^sf-classify] Google Sensitive Data Protection (Google Cloud, S3, Azure Blob);[^gcp-sdp] Macie;[^macie] Presidio (community-owned)[^presidio] | Accuracy never 100%; cost per byte; where findings land |
| Overlay data security platforms | Commvault (Satori),[^cvlt-10q] Immuta, Privacera and others; Gartner frames the category[^gartner-dsp] | Proxy vs push-down; policy-language lock-in; where evidence lives |
| Tokenisation, FPE, masking services | Vault Transform (Enterprise ADP; FF1 FPE, masking, tokenisation)[^vault-transform] | Vault availability becomes data availability |
| Key management | AWS KMS;[^kms] Snowflake composite customer-managed keys (key outage tolerated up to 10 minutes)[^sf-encryption] | A revoked key stops the platform |
| Storage perimeter and egress | S3 Block Public Access at organisation, account and bucket level;[^s3-bpa] Snowflake unload and stage parameters;[^sf-params] Purview DLP for documents[^purview-dlp] | Old buckets and policies still need review; content DLP is weak on structured data |
| Sharing | Snowflake Secure Data Sharing with secure views;[^sf-sharing][^sf-secure-views] Delta Sharing[^delta-sharing] | Region limits; secure views are slower; consumer logging is theirs |
| Audit and lineage | `ACCESS_HISTORY` (365 days, up to 3 hours latency);[^sf-access-history] BigQuery Data Access logs;[^bq-audit] `system.access.audit`;[^dbx-audit] pgAudit;[^pgaudit] OpenLineage[^openlineage] | Failed queries missing; superusers may be unauditable[^pgaudit] |

**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[^sf-rap] | Row access policy with `FILTER USING`; denied unless listed[^bq-rls] | Row filter UDF per table[^dbx-filters] | Data filter row expression (PartiQL)[^lf-filter] | `CREATE POLICY` permissive/restrictive[^pg-policy] |
| Column masking | Masking policy, directly or by tag[^sf-ddm][^sf-tagmask] | Data policy on a policy tag; fixed rule set plus custom routine[^bq-mask] | Column mask UDF[^dbx-filters] | Column include/exclude (deny, no transform)[^lf-filter] | View over the table (no native masking) |
| Tag inheritance | Tag on schema protects new tables[^sf-tagmask] | One policy tag per column; taxonomy per region[^bq-cls] | Governed tags with ABAC policies[^dbx-abac] | LF-Tags inherit database → table → column[^lf-tbac] | None |
| Known limits | Materialized views conflict; inserts not blocked[^sf-rap][^sf-tagmask] | No partition pruning with RLS; side channels via query duration[^bq-rls] | Not on views; no time travel or clones[^dbx-filters] | Read-only; `SELECT` only[^lf-filter] | Referential-integrity covert channel; owners bypass unless forced[^pg-rls] |

### 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";[^macie]
Snowflake's classification has an optional AI mode (Preview) that calls an LLM at a token cost.[^sf-classify]
**As a consumer:** copilots and agents read whatever their identity can read and restate it elsewhere.[^purview-dlp] 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`.[^sf-access-history] Give each external agent its own `SERVICE_AGENT` user;[^sf-create-user] 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](../llmops/) covers the application side.

## DataSecOps in practice

### 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[^sf-ddm][^sf-rap][^sf-classify][^sf-access-history]); 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)[^sf-wif] 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[^odcs]).

_Example status: YAML syntax-checked by the validator; illustrative values._

```yaml
# 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.[^sf-tagmask] So there is one policy per data type; its body reads
the column's tag value with `SYSTEM$GET_TAG_ON_CURRENT_COLUMN`[^sf-tagmask][^sf-tag-col] and looks up dated, dataset-scoped
exemptions with the `WHEN EXISTS (subquery)` form the reference prescribes.[^sf-create-mask] The pipeline also tags columns and
attaches the row access policy.[^sf-create-rap]

_Example status: syntax follows the Snowflake reference pages cited; not executed (requires an Enterprise account)._

```sql
-- 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:

1. A tag holds one masking policy per data type, so a `NUMBER` identifier needs its own policy.[^sf-tagmask]
2. A direct column policy overrides the tag policy, so CI fails on direct attachments the manifest does not list.[^sf-tagmask]
3. "The same column cannot be specified in both a row access policy signature and a masking policy signature at the same
   time",[^sf-rap] and a tag-bound policy puts every tagged column of its type into a masking signature; so CI never tags a
   `row_scope_key` column, and INTERNAL columns stay untagged.
4. A row access policy body runs with the policy owner's role, so `DATA_PROTECTION_ADMIN` must read `REGION_GRANTS`;[^sf-rap]
   the masking reference does not name the role that evaluates the `EXISTS` subquery,[^sf-create-mask] so grant the owner
   `SELECT` on `PLAINTEXT_GRANTS` too and test as an unprivileged role.
5. 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").[^sf-ddm][^sf-alter] So bodies change in place with `SET BODY`, the tag binding
   uses `FORCE`, 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.[^sf-gh-action][^sf-wif]

_Example status: syntax follows Snowflake's GitHub Actions and workload identity pages; workflow and SQL not run._

```sql
-- 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;
```

```yaml
# .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`,[^sf-gh-action] 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).[^gh-env]
- **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.[^gh-env] A private repository on Team approves at the merge instead:
  branch protection on `main` with 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**.[^gh-env]

**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'`.[^sf-access-history] The query flattens in that order, as
Snowflake's own example does,[^sf-access-history-guide] 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._

```sql
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,[^sf-access-history] so a missing read is not proof it did not happen.

### 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

**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`._

```python
#!/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

**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,[^pg-rls] and unique constraints leak the existence of hidden
rows.[^pg-rls] **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._

```sql
-- 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:

```sql
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.[^pg-rls] **Troubleshooting.** `permission denied
for table region_grants`: the policy runs "with the privileges of the user running the query",[^pg-rls] 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

**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.[^sf-classify] _Example status: documented, not executed._

1. Create roles `DATA_PROTECTION_ADMIN`, `SUPPORT_PII`, `ANALYST_IN`, `CRM_GLOBAL_READER`; grant the first
   `APPLY MASKING POLICY`, `APPLY ROW ACCESS POLICY` and `APPLY TAG` on the account.
2. Create `GOV.POLICY`; run the
   [reference bundle](#reference-implementation-manifest-policy-bundle-pipeline-evidence) against a copy of
   `CRM.PUBLIC.CUSTOMERS` loaded with the Lab 1 CSV.
3. Insert `('ANALYST_IN', 'IN')` into `GOV.POLICY.REGION_GRANTS`. Grant `USAGE` on a warehouse, the database and the
   schema, and `SELECT` on the table, to `ANALYST_IN`, `SUPPORT_PII` and `CRM_GLOBAL_READER`. Query as `ANALYST_IN`, then as
   a test role granted both `SUPPORT_PII` and `CRM_GLOBAL_READER`.
4. After access history catches up (up to three hours[^sf-access-history]), 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.[^sf-tagmask] **Troubleshooting.** An unmasked
`NUMBER` column means the tag has no policy for that type;[^sf-tagmask] `ADD ROW ACCESS POLICY … ON (REGION)` fails if `REGION`
carries the classification tag (unset it).[^sf-rap] **Teardown.** Drop the database and roles.

### 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[^bq-rls] | 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;[^sf-sharing][^sf-secure-views] Delta Sharing for cross-platform consumers[^delta-sharing] | 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[^unc5537] | MFA for humans, network policies, rotation, unload parameters,[^sf-params] alerts on stage and `COPY INTO` activity | Service users need a method without a password, such as workload identity federation;[^sf-mfa][^sf-wif] the phase-out runs to late 2026 and excludes reader and trial accounts[^sf-mfa] | 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),[^hipaa514] generalise to k ≥ 5,[^sweeney] document residual risk per SP 800-188[^sp800188] | Utility loss; Expert Determination where Safe Harbor destroys the analysis | Signed risk statement; re-identification test fails |

## Measure, govern, pay

### 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`[^sf-ddm] | 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)[^sf-access-history]; BigQuery `TableDataRead`[^bq-audit] | 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[^sf-access-history] |
| 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

- **Covert channels are documented, not theoretical.** PostgreSQL states that referential-integrity checks "always bypass row
  security" and warns of "covert channel" leaks;[^pg-rls] 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".[^bq-rls] Where existence itself is
  sensitive, separate the tables.
- **Writes are not reads.** Lake Formation filters apply only to reads;[^lf-filter] Snowflake row policies do not block inserts.[^sf-rap]
  Pair read policies with write grants that are at least as narrow.
- **Copies escape policies.** `CREATE OR REPLACE TABLE` and `WRITE_TRUNCATE` drop BigQuery policy tags;[^bq-cls] materialized
  views conflict with Snowflake policies;[^sf-tagmask] time travel and clones are unsupported with Unity Catalog filters.[^dbx-filters]
  Scan derived objects and propagate classification along lineage.[^ol-column]
- **Superusers are outside the model.** pgAudit says "It is not possible to reliably audit superusers";[^pgaudit] Postgres
  superusers and `BYPASSRLS` roles always bypass RLS.[^pg-rls] Keep them few, break-glass only ([AccessOps](../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".[^edpb] 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.[^cjeu-srb] 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](../privacyops/) makes the legal call.

### 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;[^dbx-filters] Snowflake warns that mapping-table lookups slow queries;[^sf-rap]
BigQuery row policies disable partition pruning, so filtered queries can bill more bytes.[^bq-rls] Classification costs serverless
credits and, in AI mode, tokens.[^sf-classify] Edition tiers are the largest line: Snowflake's policies, classification and access history
need Enterprise,[^sf-ddm] Vault's transformations the Enterprise ADP module.[^vault-transform]
Report the spend beside the plain-text read share it buys down; [FinOps](../finops/) owns allocation.

### 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,[^macie] S3 Block Public Access status[^s3-bpa]). Record all three; they are the audit evidence.

## When things go wrong

### 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[^sf-tagmask] | 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[^sf-rap][^dbx-filters] | 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;[^pg-rls] BigQuery denies unlisted principals[^bq-rls] | 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

::: figure Diagnosing a plain-text identifier that should have been masked
```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

- **"We encrypt at rest, so the data is protected."** Snowflake encrypts everything and rotates keys by
  default;[^sf-encryption] that did nothing against the valid credentials used in 2024.[^unc5537]
- **"Masking equals de-identification."** NIST SP 800-188: tools that merely mask may lack "sufficient functionality for
  performing de-identification".[^sp800188] 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.[^bq-mask] 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

### 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](../dbops/) suffice); when the data is public or synthetic; or when identity basics are missing (no
central groups, no MFA): fix those with [IAMOps](../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

::: figure Choosing masking, tokenisation, encryption or de-identification for a field
```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

This Atlas's model, not an industry standard; CISA's zero-trust stages[^cisa-ztmm] 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

- **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

### 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

**Classification standard (one page).** _Template status: text._

```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._

```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._

```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

| 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[^bq-mask] |
| Tokenisation | Yes, via vault | Only if convergent[^vault-tokenize] | Yes (vault exists) | Keep identifiers out of the platform[^vault-transform] |
| Format-preserving encryption | Yes, via key | Yes | Yes | Legacy schemas, fixed-format fields[^vault-transform] |
| Pseudonymisation (domain-designed) | Depends on key custody | Yes | Yes for the key holder; possibly not for a recipient without it[^cjeu-srb] | Internal analytics, partner and research sharing |
| De-identification (k-anonymity, DP) | No | Limited | Only while re-identification remains reasonably likely[^cjeu-srb][^sp800188] | External releases |

### FAQ

**Is DataSecOps a recognised discipline?** No. It is a vendor-coined label (Satori, 2021; part of Commvault since
2025)[^hns2021][^cvlt-10q] 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",[^gdpr32] and the EDPB says pseudonymisation "alone will normally not be a sufficient measure".[^edpb] Lawful basis,
minimisation and rights handling are separate obligations ([PrivacyOps](../privacyops/)).

**Can I rely on the scanner's classification?** No. Presidio warns "there is no guarantee that Presidio will find all sensitive
information",[^presidio] and Google makes the same caveat.[^gcp-sdp] 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

### 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,[^dbx-abac] LF-Tags[^lf-tbac]), each in its own dialect.
- **Contracts and lineage as tag sources (emerging):** ODCS v3.2.0 carries `classification` and `encryptedName` per
  property,[^odcs] and OpenLineage's column lineage facet records whether a transformation masked its input,[^ol-column] 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;[^sp800226] expect it in query interfaces for external analysis, not everyday BI.
- **Agents as consumers (emerging):** per-agent identities exist (Snowflake's `SERVICE_AGENT` users, logged as
  `EXTERNAL_AGENT`[^sf-access-history]); purpose-bound grants for agents are still speculative.

### Learning path

- **Prerequisites.** SQL; role-based access in one platform; the basics of [DataOps](../dataops/) and [IAMOps](../iamops/).
- **One day.** Run Lab 1; read your platform's masking and row-policy introductions.[^sf-ddm][^sf-rap][^bq-cls][^bq-rls]
- **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,[^sp800188] the EDPB
  guidelines[^edpb] and the 2025 CJEU ruling;[^cjeu-srb] design a pseudonymisation domain for one dataset.
- **Specialisation.** De-identification and differential privacy;[^sp800226] multi-platform policy compilation. **Portfolio
  project:** a policy repository with CI, a drift check and a scorecard for a sample warehouse.

## Wrap-up

### 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?

**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.[^unc5537] 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;[^edpb] A has no domain boundary. The tokens stay personal data for the
company, which runs the vault;[^cjeu-srb] B cuts risk, not legal duties. (3) *Cost of B.* A vault in the ingestion path, an
availability dependency and an Enterprise module;[^vault-transform] joins survive only if the tokenisation is convergent, which
Vault says to enable only if needed.[^vault-tokenize] (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

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.

::: answer 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.[^gdpr4][^cjeu-srb] Check who can map
hashes back (anyone with an address list), whether the hash is salted per
query or joinable,[^bq-mask] and whether lawful basis and minimisation were addressed at all.
:::

::: answer 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.[^pg-rls] Force it; keep the owner role out of daily use.
:::

::: answer 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.[^sf-tagmask] Add
the policy and make CI check that every tagged type has one.
:::

::: answer 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.[^sf-access-history]
:::

::: answer 5. The hospital wants to share patient outcomes with a university by removing names and medical record numbers. Enough?
No. Safe Harbor requires removing 18 identifier types, including dates (except year), small-area geography and ages over
89,[^hipaa514] and quasi-identifiers can still re-identify (Sweeney's 87%).[^sweeney] Generalise, measure k, and have a named
person sign the residual risk per SP 800-188.[^sp800188]
:::

### 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

All sources opened on 2026-10-17; platform documentation is live and carries no publication date unless shown. Ledger:
`research/datasecops-ledger.md`.

[^hns2021]: [What is DataSecOps and why it matters](https://www.helpnetsecurity.com/2021/08/04/what-is-datasecops/) — Help Net Security (podcast transcript with Ben Herzberg, Satori), 2021-08-04. Checked 2026-10-17.
[^satori-page]: [DataSecOps](https://satoricyber.com/datasecops/) — Satori Cyber (vendor page, undated). Checked 2026-10-17.
[^satori-vs]: [DataOps vs DataSecOps](https://satoricyber.com/dataops/dataops-vs-datasecops/) — Satori Cyber (vendor page, undated). Checked 2026-10-17.
[^cvlt-10q]: [Form 10-Q for the quarter ended September 30, 2025](https://www.sec.gov/Archives/edgar/data/1169561/000116956125000089/cvlt-20250930.htm) — Commvault Systems, Inc., SEC EDGAR, 2025. Checked 2026-10-17.
[^nextlabs]: [What is DataSecOps?](https://www.nextlabs.com/blogs/what-is-datasecops/) — NextLabs community blog (vendor, undated). Checked 2026-10-17.
[^ibsec]: [DataSecOps: Segurança em Ambientes Híbridos no Itaú](https://ibsec.com.br/datasecops-seguranca-em-ambientes-hibridos-no-itau/) — IBSEC (Portuguese), 2026-09-26. Checked 2026-10-17.
[^gartner-dsp]: [Gartner Report: Market Guide for Data Security Platforms](https://cpl.thalesgroup.com/resources/encryption/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.
[^wiz-dspm]: [Data security posture management (DSPM)](https://www.wiz.io/academy/data-security-posture-management-dspm) — Wiz Academy (vendor, undated). Checked 2026-10-17.
[^unc5537]: [UNC5537 Targets Snowflake Customer Instances for Data Theft and Extortion](https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion) — Mandiant / Google Cloud, 2024-06-10. Checked 2026-10-17.
[^sf-mfa]: [Planning for the deprecation of single-factor password sign-ins](https://docs.snowflake.com/en/user-guide/security-mfa-rollout) — Snowflake Documentation. Checked 2026-10-17.
[^capone]: [Information on the Capital One cyber incident](https://www.capitalone.com/digital/facts2019/) — Capital One, 2019. Checked 2026-10-17.
[^occ]: [OCC Assesses $80 Million Civil Money Penalty Against Capital One](https://www.occ.gov/news-issuances/news-releases/2020/nr-occ-2020-101.html) — Office of the Comptroller of the Currency, 2020-08-06. Checked 2026-10-17.
[^ibm2026]: [Cost of a Data Breach Report 2026](https://www.ibm.com/reports/data-breach) — IBM and Ponemon Institute, 2026. Checked 2026-10-17.
[^sp80053]: [SP 800-53 Rev. 5: Security and Privacy Controls for Information Systems and Organizations](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final) — NIST, 2020 (Release 5.2.0, 2025-08-27). Checked 2026-10-17.
[^csf2]: [The NIST Cybersecurity Framework (CSF) 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final) — NIST, 2024-02-26. Checked 2026-10-17.
[^sp800188]: [SP 800-188: De-Identifying Government Datasets: Techniques and Governance](https://csrc.nist.gov/pubs/sp/800/188/final) — NIST, 2023-09. Checked 2026-10-17.
[^sp800226]: [SP 800-226: Guidelines for Evaluating Differential Privacy Guarantees](https://csrc.nist.gov/pubs/sp/800/226/final) — NIST, 2025-03. Checked 2026-10-17.
[^sp800207]: [SP 800-207: Zero Trust Architecture](https://csrc.nist.gov/pubs/sp/800/207/final) — NIST, 2020-08. Checked 2026-10-17.
[^cisa-ztmm]: [Zero Trust Maturity Model](https://www.cisa.gov/zero-trust-maturity-model) — CISA, Version 2.0 (2023). Checked 2026-10-17.
[^gdpr4]: [Art. 4 GDPR — Definitions](https://gdpr-info.eu/art-4-gdpr/) — Regulation (EU) 2016/679, unofficial consolidated text (Intersoft Consulting). Checked 2026-10-17.
[^gdpr25]: [Art. 25 GDPR — Data protection by design and by default](https://gdpr-info.eu/art-25-gdpr/) — Regulation (EU) 2016/679, unofficial consolidated text. Checked 2026-10-17.
[^gdpr32]: [Art. 32 GDPR — Security of processing](https://gdpr-info.eu/art-32-gdpr/) — Regulation (EU) 2016/679, unofficial consolidated text. Checked 2026-10-17.
[^edpb]: [Guidelines 01/2025 on Pseudonymisation (version for public consultation, PDF)](https://www.edpb.europa.eu/system/files/2025-01/edpb_guidelines_202501_pseudonymisation_en.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.
[^cjeu-srb]: [Judgment in Case C-413/23 P, EDPS v SRB](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:62023CJ0413) — 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.
[^hipaa514]: [45 CFR § 164.514 — Other requirements relating to uses and disclosures of protected health information](https://www.law.cornell.edu/cfr/text/45/164.514) — Legal Information Institute, Cornell (eCFR mirror). Checked 2026-10-17.
[^pci-faq]: [What is the difference between masking and truncation?](https://www.pcisecuritystandards.org/faq/articles/Frequently_Asked_Question/what-is-the-difference-between-masking-and-truncation/) — PCI Security Standards Council FAQ. Checked 2026-10-17.
[^sweeney]: [k-anonymity: a model for protecting privacy](https://dataprivacylab.org/dataprivacy/projects/kanonymity/kanonymity.pdf) — Latanya Sweeney, International Journal on Uncertainty, Fuzziness and Knowledge-based Systems 10(5), 2002. Checked 2026-10-17.
[^sf-ddm]: [Dynamic Data Masking](https://docs.snowflake.com/en/user-guide/security-column-ddm-intro) — Snowflake Documentation. Checked 2026-10-17.
[^sf-rap]: [Understanding row access policies](https://docs.snowflake.com/en/user-guide/security-row-intro) — Snowflake Documentation. Checked 2026-10-17.
[^sf-tagmask]: [Tag-based masking policies](https://docs.snowflake.com/en/user-guide/tag-based-masking-policies) — Snowflake Documentation. Checked 2026-10-17.
[^sf-create-mask]: [CREATE MASKING POLICY](https://docs.snowflake.com/en/sql-reference/sql/create-masking-policy) — Snowflake Documentation. Checked 2026-10-17.
[^sf-create-rap]: [CREATE ROW ACCESS POLICY](https://docs.snowflake.com/en/sql-reference/sql/create-row-access-policy) — Snowflake Documentation. Checked 2026-10-17.
[^sf-access-history]: [ACCESS_HISTORY view](https://docs.snowflake.com/en/sql-reference/account-usage/access_history) — Snowflake Documentation. Checked 2026-10-17.
[^sf-classify]: [Sensitive data classification](https://docs.snowflake.com/en/user-guide/classify-intro) — Snowflake Documentation. Checked 2026-10-17.
[^sf-encryption]: [Understanding encryption key management in Snowflake](https://docs.snowflake.com/en/user-guide/security-encryption-manage) — Snowflake Documentation. Checked 2026-10-17.
[^sf-secure-views]: [Working with secure views](https://docs.snowflake.com/en/user-guide/views-secure) — Snowflake Documentation. Checked 2026-10-17.
[^sf-sharing]: [Introduction to Secure Data Sharing](https://docs.snowflake.com/en/user-guide/data-sharing-intro) — Snowflake Documentation. Checked 2026-10-17.
[^bq-cls]: [Introduction to column-level access control](https://docs.cloud.google.com/bigquery/docs/column-level-security-intro) — Google Cloud BigQuery documentation. Checked 2026-10-17.
[^bq-mask]: [Introduction to data masking](https://docs.cloud.google.com/bigquery/docs/column-data-masking-intro) — Google Cloud BigQuery documentation. Checked 2026-10-17.
[^bq-rls]: [Introduction to row-level security](https://docs.cloud.google.com/bigquery/docs/row-level-security-intro) — Google Cloud BigQuery documentation. Checked 2026-10-17.
[^bq-audit]: [BigQuery audit logs overview](https://docs.cloud.google.com/bigquery/docs/reference/auditlogs) — Google Cloud BigQuery documentation. Checked 2026-10-17.
[^dbx-filters]: [Row filters and column masks](https://docs.databricks.com/aws/en/tables/row-and-column-filters) — Databricks Documentation. Checked 2026-10-17.
[^dbx-abac]: [Attribute-based access control (ABAC)](https://docs.databricks.com/aws/en/data-governance/unity-catalog/abac/) — Databricks Documentation. Checked 2026-10-17.
[^dbx-audit]: [Audit log system table reference](https://docs.databricks.com/aws/en/admin/system-tables/audit-logs) — Databricks Documentation. Checked 2026-10-17.
[^lf-filter]: [Data filtering and cell-level security in Lake Formation](https://docs.aws.amazon.com/lake-formation/latest/dg/data-filtering.html) — AWS Lake Formation Developer Guide. Checked 2026-10-17.
[^lf-tbac]: [Lake Formation tag-based access control](https://docs.aws.amazon.com/lake-formation/latest/dg/tag-based-access-control.html) — AWS Lake Formation Developer Guide. Checked 2026-10-17.
[^pg-rls]: [Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) — PostgreSQL documentation (current). Checked 2026-10-17.
[^pg-policy]: [CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html) — PostgreSQL documentation (current). Checked 2026-10-17.
[^pg95]: [PostgreSQL 9.5 release notes](https://www.postgresql.org/docs/release/9.5.0/) — PostgreSQL Global Development Group, 2016-01-07. Checked 2026-10-17.
[^pgaudit]: [pgAudit: PostgreSQL Audit Extension (README)](https://github.com/pgaudit/pgaudit) — pgaudit project on GitHub. Checked 2026-10-17.
[^ranger]: [Apache Ranger](https://ranger.apache.org/) — Apache Software Foundation. Checked 2026-10-17.
[^ranger-hive]: [Row-level filtering and column-masking using Apache Ranger policies in Apache Hive](https://cwiki.apache.org/confluence/display/RANGER/Row-level+filtering+and+column-masking+using+Apache+Ranger+policies+in+Apache+Hive) — Apache Ranger wiki. Checked 2026-10-17.
[^vault-transform]: [Transform secrets engine](https://developer.hashicorp.com/vault/docs/secrets/transform) — HashiCorp Vault Documentation. Checked 2026-10-17.
[^kms]: [AWS KMS keys (concepts)](https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html) — AWS Key Management Service Developer Guide. Checked 2026-10-17.
[^s3-bpa]: [Blocking public access to your Amazon S3 storage](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html) — Amazon S3 User Guide. Checked 2026-10-17.
[^macie]: [What is Amazon Macie?](https://docs.aws.amazon.com/macie/latest/user/what-is-macie.html) — Amazon Macie User Guide. Checked 2026-10-17.
[^gcp-sdp]: [Data profiles](https://docs.cloud.google.com/sensitive-data-protection/docs/data-profiles) — Google Cloud Sensitive Data Protection documentation. Checked 2026-10-17.
[^purview-dlp]: [Learn about data loss prevention](https://learn.microsoft.com/en-us/purview/dlp-learn-about-dlp) — Microsoft Learn, updated 2026-06-26. Checked 2026-10-17.
[^presidio]: [Presidio documentation](https://presidio.dataprivacystack.org/) — Data Privacy Stack (formerly Microsoft). Checked 2026-10-17.
[^openlineage]: [OpenLineage documentation](https://openlineage.io/docs/) — OpenLineage project (LF AI & Data). Checked 2026-10-17.
[^ol-column]: [Column level lineage dataset facet](https://openlineage.io/docs/spec/facets/dataset-facets/column_lineage_facet) — OpenLineage specification. Checked 2026-10-17.
[^delta-sharing]: [Delta Sharing](https://delta.io/sharing/) — Delta Lake project (Linux Foundation). Checked 2026-10-17.
[^odcs]: [Open Data Contract Standard v3.2.0 — schema](https://github.com/bitol-io/open-data-contract-standard/blob/v3.2.0/docs/schema.md) and [release v3.2.0](https://github.com/bitol-io/open-data-contract-standard/releases/tag/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_at` 2026-09-08T16:13:35Z). Checked 2026-10-17.
[^sf-tag-col]: [SYSTEM$GET_TAG_ON_CURRENT_COLUMN](https://docs.snowflake.com/en/sql-reference/functions/system_get_tag_on_current_column) and [SYSTEM$GET_TAG_ON_CURRENT_TABLE](https://docs.snowflake.com/en/sql-reference/functions/system_get_tag_on_current_table) — Snowflake Documentation. Checked 2026-10-17.
[^sf-access-history-guide]: [Access History (user guide, policy query examples)](https://docs.snowflake.com/en/user-guide/access-history) — Snowflake Documentation. Checked 2026-10-17.
[^sf-params]: [Parameters](https://docs.snowflake.com/en/sql-reference/parameters) — Snowflake Documentation (`PREVENT_UNLOAD_*`, `REQUIRE_STORAGE_INTEGRATION_*` and `DISABLE_UI_DOWNLOAD_BUTTON` entries). Checked 2026-10-17.
[^sf-create-user]: [CREATE USER](https://docs.snowflake.com/en/sql-reference/sql/create-user) — Snowflake Documentation (`TYPE` values, including `SERVICE_AGENT`). Checked 2026-10-17.
[^sf-wif]: [Workload identity federation](https://docs.snowflake.com/en/user-guide/workload-identity-federation) — Snowflake Documentation. Checked 2026-10-17.
[^sf-gh-action]: [Snowflake CLI GitHub Action](https://docs.snowflake.com/en/developer-guide/snowflake-cli/cicd/github-action) — Snowflake Documentation (OIDC setup and subject formats). Checked 2026-10-17.
[^vault-tokenize]: [Tokenization transform](https://developer.hashicorp.com/vault/docs/secrets/transform/tokenization) — HashiCorp Vault Documentation (convergent tokenization). Checked 2026-10-17.
[^sf-alter]: [ALTER MASKING POLICY](https://docs.snowflake.com/en/sql-reference/sql/alter-masking-policy), [ALTER ROW ACCESS POLICY](https://docs.snowflake.com/en/sql-reference/sql/alter-row-access-policy), [ALTER TAG](https://docs.snowflake.com/en/sql-reference/sql/alter-tag), [ALTER TABLE](https://docs.snowflake.com/en/sql-reference/sql/alter-table), [POLICY_REFERENCES](https://docs.snowflake.com/en/sql-reference/functions/policy_references) and [Working with variables](https://docs.snowflake.com/en/developer-guide/snowflake-scripting/variables) — Snowflake Documentation. Checked 2026-10-17.
[^gh-env]: [Deployments and environments](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments), [Managing environments for deployment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), [About protected branches](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) and [GitHub's plans](https://docs.github.com/en/get-started/learning-about-github/githubs-plans) — GitHub Docs. Checked 2026-10-17.

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).
