Skip to content
Stribog

Compliance

All writing

Data Classification Tools: Where Labels Live, What Enforces

Presidio, OpenMetadata, Atlas with Ranger and PostgreSQL Anonymizer compared on where classification labels live, what enforces them, and the evidence left.

Stribog13 min read

Most classification schemes are written for an auditor and never connected to the systems that hold the data. A spreadsheet says the customers.phone column is Confidential; the database serves it in clear text to every role with SELECT. The fix is deciding, per data store, which component reads the label and refuses the request, usually two open-source projects wired together rather than one product.

A classification label nobody reads is documentation

Control frameworks ask for more than a policy PDF. NIST SP 800-53 Rev. 5 control AC-16, Security and Privacy Attributes, asks an organisation to "provide the means to associate" attributes with "information in storage, in process, and/or in transmission", to "ensure that the attribute associations are made and retained with the information", and to "audit changes to attributes" (NIST OSCAL catalogue). The organisation defines which attributes; the control defines where they must sit — with the data.

The control's own guidance is precise about what is labelled. Security attributes are "a form of metadata" representing properties of subjects and objects, and objects are "records, buffers, tables, files" and similar data structures. RA-2, Security Categorization, is the companion, with three obligations: categorise the system and the information it "processes, stores, and transmits"; document the result with its rationale in the security plan; and have the authorizing official or a designated representative review and approve that decision. RA-2 governs the categorisation decision. AC-16 governs the label itself: bound to the information, retained with it, its changes audited.

That gap is the whole subject of this article. Any self-hosted classification stack does three distinct jobs, and most products do one of them well:

  • Detect — inspect names and values and propose a label with a confidence score.
  • Record — store the label against an asset so people can find, review and report on it.
  • Enforce — read the label at request time and change the answer: deny, mask, or allow.

Only the third job changes who can read a column. The first two alone produce an inventory of data exactly as exposed as before. This is the audit-grade-rigor pillar applied to data: NIST 800-53 control mapping on infrastructure you own is only credible if the attributes are machine-read, not merely written down.

Three jobs, four projects: who detects, who records, who enforces

Managed DLP suites are the category most buyers compare against; this article makes no claims about them. The self-hostable field splits cleanly on the three layers:

  • Presidio (MIT licence) — a detector. Its analyzer finds PII in text; separate modules anonymise text, redact images, and handle structured and semi-structured data. It records nothing and enforces nothing on a data store.
  • OpenMetadata (Apache 2.0) — a catalogue with a built-in detector. Its Auto-Classification workflow proposes tags and writes them to the OpenMetadata server.
  • DataHub — a catalogue. Its built-in classifier has been removed; the classification framework remains for a classifier you register yourself.
  • Apache Atlas + Apache Ranger — a recorder wired to an enforcer. Atlas holds classifications and propagates them along lineage; ranger-tagsync feeds them to Ranger, whose plugins enforce tag-based policies inside the protected services.
  • PostgreSQL Anonymizer — the label lives in the database. Masking rules are SECURITY LABELs on columns and roles, enforced by the server for every masked role.
Solid edges exist out of the box. Every dashed edge is a piece of glue — code or a human — that a classification programme has to own.

Read the diagram as a gap analysis. Atlas to Ranger is the one fully wired record-to-enforce path here. Everything that ends at a PostgreSQL column either goes through a human writing DDL or through glue nobody ships for you. That is the honest shape of the problem, and it reduces a four-project comparison to a two-way one: catalogue labels versus enforcement-point labels.

Detection proposes labels; it does not decide them

Presidio's recognisers combine named-entity recognition, regular expressions, rule-based logic and checksums, and it runs from Python or PySpark through Docker to Kubernetes. Its documentation is candid: "because it is using automated detection mechanisms, there is no guarantee that Presidio will find all sensitive information. Consequently, additional systems and protections should be employed" (Presidio docs). Treat that sentence as a design requirement, not a disclaimer.

The output makes the point concrete. The analyzer returns entity types, character spans and scores — candidates, not decisions. The default NLP engine is spaCy, so the model must be downloaded first:

python
# pip install presidio-analyzer && python -m spacy download en_core_web_lg
from presidio_analyzer import AnalyzerEngine

analyzer = AnalyzerEngine()
text = "Ticket opened by Jane Doe, reply to jane.doe@example.com"

results = analyzer.analyze(text=text, language="en")
for r in sorted(results, key=lambda r: r.start):
    print(f"{r.entity_type:<14} {text[r.start:r.end]!r:<28} score={r.score:.2f}")
Each result is a typed span with a confidence score. Somebody, or some rule, still has to decide what becomes a label.

OpenMetadata's Auto-Classification does the same job on tables: it scans "both column names and sample data during ingestion, then applying or suggesting tags like PII.Sensitive and PII.NonSensitive" (OpenMetadata docs). Its confidence parameter runs 0–100 with a default of 80 and sets the minimum score for tagging a column PII.Sensitive. The docs state the trade-off plainly: a higher value reduces false positives but may miss sensitive data; a lower value finds more and produces more false positives. There is no setting that buys both.

PostgreSQL Anonymizer ships a third detector, anon.detect(), which searches column names against a dictionary of identifier categories based on the HIPAA classification. Its documentation warns of false positives and, more seriously, false negatives and says to treat it "only" as "a helping tool" while still reviewing the entire data model.

Catalogue labels: OpenMetadata and DataHub

OpenMetadata describes Classification as "a controlled vocabulary" that "can be used for labeling data", and Tiers as defining "the importance of data to an organization." The external Auto-Classification workflow is a YAML file run with metadata classify -c <file> after pip install "openmetadata-ingestion[pii-processor]". The shape below follows the documented workflow (AutoClassification source config, tag-pii-processor, metadata-rest sink), with Postgres connection keys taken from OpenMetadata's connection schema:

yaml
source:
  type: postgres
  serviceName: pg-orders
  serviceConnection:
    config:
      type: Postgres
      username: om_classifier
      authType:
        password: "${OM_CLASSIFIER_PASSWORD}"
      hostPort: "pg-orders.internal:5432"
      database: orders
  sourceConfig:
    config:
      type: AutoClassification
      storeSampleData: false
      enableAutoClassification: true
      confidence: 85
      schemaFilterPattern:
        includes:
          - public
processor:
  type: "tag-pii-processor"
  config: {}
sink:
  type: metadata-rest
  config: {}
workflowConfig:
  openMetadataServerConfig:
    hostPort: "http://openmetadata.internal:8585/api"
    authProvider: openmetadata
    securityConfig:
      jwtToken: "${OM_INGESTION_BOT_JWT}"
Render the two placeholders from your secret store (for example with envsubst) at run time; never commit them. Confidence 85 trades a little recall for fewer false positives.

That workflow writes to the metadata-rest sink — the OpenMetadata server. OpenMetadata's own access control is built from rules that name resources (such as Table) and operations (such as EditOwners) inside OpenMetadata, grouped into policies and roles. That governs who may edit the catalogue's description of the table.

Stribog's reading of how the layers fit: nothing in that documented open workflow changes who can run SELECT against orders.public.customers. The tag describes the column; the database never consults it. If your design depends on pushing metadata back to sources, name that feature and test it.

DataHub belongs on the same layer. Its documentation now states that the built-in datahub classifier (DataHubClassifier) "has been removed"; the classification framework — the Classifier interface, the registry and per-source orchestration — is retained so you can register your own, and a recipe with classification.enabled: true and no registered classifier fails at startup. If you need the old behaviour, the last release that ships it is acryl-datahub==1.6.0.5. Pin it deliberately, not by default.

Enforcement-point labels: Ranger tag policies and Postgres security labels

Atlas classifications, Ranger tag policies

In Apache Atlas a classification added to an entity propagates along lineage: add PII to an hdfs_path and it reaches the table derived from it and the views over that table (Atlas 2.0.0 propagation docs). Each classification-to-entity association carries a boolean propagate flag, default true — an all-or-nothing switch that sends the classification to every impacted entity or to none. Where a transformation genuinely removes the sensitive content, such as a PII column masked when a view is created, the control is different: add PII to that lineage edge's blocked-propagated-classifications list, so it stops at that edge while every other downstream entity keeps it.

Ranger's ranger-tagsync module populates Ranger's tag store from Atlas via change notifications. Ranger's design documentation for tag-based policies is explicit about the wiring step teams miss: "for the plugins to also enforce tag-based policies, the component service must be updated to refer to a specific tag service instance". Skip it and the tags sync perfectly and are never evaluated.

Deny on any tag wins. A tag allow ends evaluation too — resource policies are only consulted when no tag policy produced a result.

Evaluation order, per that design page, matters most: if a policy for one of the resource's tags results in deny, access is denied; if none deny and a policy allows for one of the tags, access is allowed; only if there is no result for any tag, or the resource has no tags, does the engine evaluate resource-based policies. A restricted tag with a deny policy therefore overrides any resource grant, which is what a classification scheme wants. A careless tag allow also short-circuits your resource policies, which is not.

Ranger's README describes the runtime: policies are managed in Ranger Admin, and each protected service runs a plugin that "downloads the service's policies from Ranger Admin, authorizes each request locally and sends audit events to the audit store." Its Docker dev setup includes plugins for Trino, Ozone, Kafka, Hive and HBase. Ranger enforces only inside services that ship a plugin or call its authorization API, hence a different project for Postgres.

PostgreSQL security labels and Anonymizer masking

PostgreSQL already has a place for a label that lives with the data. SECURITY LABEL attaches a label to a database object, one per label provider, and PostgreSQL "merely provides a mechanism for storing them" in the pg_seclabel catalog, with pg_seclabels as the easier-to-query view. The provider gives the label meaning. PostgreSQL Anonymizer is such a provider, and its approach is declarative: masking rules are DDL, declared against the table itself.

Preload first. Outside the project's Docker image, the anon library must be loaded before CREATE EXTENSION: either per database with session_preload_libraries (a reconnect is enough) or globally via shared_preload_libraries plus a restart — the recommended way, and required for Replica Masking, since session preloading does not cover background workers. ALTER SYSTEM SET shared_preload_libraries = 'anon' replaces the whole list, so append anon to whatever you already preload. Then run the script below in that same preloaded database — orders here; the first line connects to it, so every statement, including the ALTER DATABASE, targets one database:

sql
-- Run in psql as a superuser. Every statement targets the orders database.
\connect orders
-- Precondition: the anon library is already preloaded for orders.
-- On a stock server, first run (as superuser) and reconnect:
--   ALTER DATABASE orders SET session_preload_libraries = 'anon';
-- or append anon to shared_preload_libraries and restart PostgreSQL.
CREATE EXTENSION IF NOT EXISTS anon;
SELECT anon.init();

CREATE TABLE IF NOT EXISTS customers (
  id        bigint PRIMARY KEY,
  last_name text,
  phone     text
);

ALTER DATABASE orders SET anon.transparent_dynamic_masking TO true;

SECURITY LABEL FOR anon ON COLUMN customers.last_name
  IS 'MASKED WITH FUNCTION anon.dummy_last_name()';
SECURITY LABEL FOR anon ON COLUMN customers.phone
  IS 'MASKED WITH FUNCTION anon.partial(phone,2,$$******$$,2)';

CREATE ROLE analyst LOGIN;
SECURITY LABEL FOR anon ON ROLE analyst IS 'MASKED';
GRANT pg_read_all_data TO analyst;

-- Evidence: every label the anon provider holds in this database.
SELECT objtype, objname, label
FROM pg_seclabels
WHERE provider = 'anon'
ORDER BY objtype, objname;
The masking setting applies to new connections: reconnect as analyst before testing, or the check runs in a session that predates it.

Now the label is the enforcement. analyst reads masked values; other roles still see the original data. The documented limits are worth stating up front: masked roles cannot use EXPLAIN; masked roles "should not be allowed to insert, update or delete data", which is why the grant above is read-only (pg_read_all_data reads every table, view and sequence; masking is per labelled column, so any column without a label comes back in clear whatever the grant's scope); row security policies can be layered on top; and only one masking policy can apply to a role, though several named policies may exist. Unmasking is SECURITY LABEL FOR anon ON ROLE analyst IS NULL — a DDL change, visible in the catalog, auditable like any other. For a database run under an operator, this sits naturally on PostgreSQL on Kubernetes with CloudNativePG, where the extension and preload settings become part of declared cluster configuration.

A namespace label is a classification attribute on a workload boundary, and Kyverno can require it. In Audit mode a failing resource is allowed but recorded in a policy report, and existing resources are reported during periodic background scans — the gap is visible before Enforce. The per-rule validate.failureAction field is the current form:

yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-data-classification
spec:
  background: true
  rules:
    - name: namespace-has-data-classification
      match:
        any:
          - resources:
              kinds:
                - Namespace
      validate:
        failureAction: Audit
        message: "Every namespace needs a data-classification label: public, internal, confidential or restricted."
        pattern:
          metadata:
            labels:
              data-classification: "public | internal | confidential | restricted"
Audit first, Enforce after the policy reports are clean. The label then becomes something admission, network and backup policy can select on.

For how that admission layer scales beyond one rule, see Kyverno vs OPA Gatekeeper.

What each layer can show an auditor

AC-16 asks for associations retained with the information and for changes to be audited. Each layer can produce a different piece of that, and none produces all of it:

  • Postgres — pg_seclabels is the current state of every masking rule and masked role, queryable as plain SQL. Label changes are DDL, so they appear wherever you already capture DDL.
  • Ranger — the audit trail of access decisions the plugins send, which is evidence that enforcement happened, not just that a policy existed.
  • Atlas — which entities carry a classification and whether it was applied directly or propagated along lineage.
  • Catalogue — the change history on tags in OpenMetadata or DataHub: who classified what, and when. Evidence of the scheme, not of enforcement.
  • Kyverno — policy reports listing namespaces that pass or fail the classification rule.

Collect them on a schedule into a dated, hashed directory. Namespace is cluster-scoped, so Kyverno writes its results into ClusterPolicyReports:

bash
#!/usr/bin/env bash
set -euo pipefail

: "${PGDATABASE:?set PGDATABASE (plus PGHOST and PGUSER) for the database to evidence}"
stamp="$(date -u +%Y-%m-%dT%H%M%SZ)"
out="evidence/classification-${stamp}"
mkdir -p "$out"

psql -X -v ON_ERROR_STOP=1 -c "\copy (SELECT provider, objtype, objname, label FROM pg_seclabels ORDER BY 1, 2, 3) TO '${out}/pg_seclabels.csv' WITH (FORMAT csv, HEADER)"

kubectl get clusterpolicyreports -o json \
  | jq '[.items[]
         | {report: .metadata.name, scope: .scope,
            results: [.results[]? | select(.policy == "require-data-classification")
                      | {rule, result, message}]}
         | select(.results | length > 0)]' \
  > "${out}/kyverno-namespace-classification.json"

(cd "$out" && sha256sum -- * > SHA256SUMS)
echo "evidence written to ${out}"
Two sources, one manifest. The SHA256SUMS file lets a reviewer confirm the export was not edited after collection.

The same evidence discipline underpins GDPR Article 32 technical measures an auditor can verify: a control is the configuration plus the artefact that proves it ran.

Failure modes: drift, stale tags, broken lineage

Labels and enforcement diverge in predictable ways, each derived from behaviour the projects document themselves. Test for each one explicitly:

  • Classifier false negatives. Presidio, OpenMetadata and anon.detect() all document missed detections. A column nobody labels is a column nobody masks. Pair every detector with a human review of new schemas.
  • Broken lineage drops tags. In Atlas (per the 2.0.0 propagation docs), deleting an entity in the middle of a lineage path breaks propagation, and previously propagated classifications are removed from everything derived from it. Atlas's Case 3 is the exception — an alternate lineage path keeps propagated tags — so the risk sits on single-path lineage. Re-check classifications after pipeline refactors.
  • Cache lag. Ranger plugins cache tags and periodically poll the tag store. A newly applied restricted tag is not enforced until the next poll; size your expectations, and your incident runbooks, to that interval.
  • Masked roles that can write. Anonymizer's docs say masked roles should not insert, update or delete. A masked role with write grants is a configuration error your evidence query should catch.
  • Assuming MAC. PostgreSQL's sepgsql provides SELinux label-based mandatory access control on Linux with SELinux enabled, but its documentation warns that the current implementation "does not enforce mandatory access control for all actions." Do not present it to an auditor as complete MAC.

Exit ramps and the long game

A classification scheme should outlive every tool in this article. Keep the vocabulary in version control as the source of truth, and treat every product as a projection of it. The projections are portable: Postgres labels are SQL DDL you can dump and replay; Atlas and Ranger expose REST APIs; OpenMetadata workflows are YAML and its catalogue has an API. That is sovereignty over the data's description, which is a precondition for sovereignty over the data — including the separate question of where it physically sits.

Governance is part of the long game too. Presidio has completed a transition, supported by Microsoft, from a Microsoft-owned project to an independent, community-governed one under the Data Privacy Stack organisation. MCR images are no longer updated, so image references should move to ghcr.io/data-privacy-stack/presidio-*. If Presidio already guards prompts in your LiteLLM gateway, that is the same image change. Pin image sources; do not assume they never move.

The decision is not which data classification tool to buy but which reader each label needs. Detect with whatever finds most for the fewest false positives; record in a catalogue people use; and for every store holding restricted data, name the component that reads the label at request time. If you cannot name it, the label is documentation.

§FAQ/Common questions

Frequently asked

What is the difference between a data classification tool and data enforcement?

A classification tool detects sensitive data and records a label against it, usually in a catalogue. Enforcement is a component that reads that label at request time and changes the answer — Ranger plugins evaluating tag-based policies, or PostgreSQL Anonymizer masking columns for roles labelled MASKED. Without an enforcement-point reader, the label documents risk but does not reduce it.

Does tagging a column PII.Sensitive in OpenMetadata restrict access to it?

Not on its own. The documented Auto-Classification workflow writes tags to the OpenMetadata server through the metadata-rest sink, and OpenMetadata's access rules govern operations on catalogue entities. Restricting who can read the column in the database needs an enforcement point there, such as PostgreSQL Anonymizer security labels.

Can Presidio find all personal data in a database?

No, and its documentation says so: because it uses automated detection, there is no guarantee it will find all sensitive information, and additional systems and protections should be employed. Treat it as a detector whose output a human or a rule must confirm.

How does Apache Ranger decide between tag and resource policies?

Ranger evaluates tag-based policies first. If any tag policy denies, access is denied. Otherwise, if a tag policy allows for one of the tags, access is allowed. Only when there is no result for any tag, or the resource has no tags, are resource-based policies evaluated.

What evidence satisfies NIST 800-53 AC-16 for data labels?

AC-16 asks for attributes associated with information, retained with it, and for changes to be audited. Useful evidence is the stored labels themselves (for example a pg_seclabels export), the enforcement point's access-decision audit trail, and the change history of the labels — collected on a schedule and hashed.

data classification toolself-hosted data classificationdata classification labels enforcementopenmetadata auto-classification piiapache ranger tag-based policies atlaspostgresql anonymizer security label masking

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.