Data Access Governance for GDPR and CCPA Compliance in Postgres
Three Postgres layers enforce data access rules that privacy laws demand.

Restricting which columns a non-technical team can see in an internal database tool comes down to three layers working together: role-based access control (who can touch a table at all), row-level security (which records within that table they can see), and column-level privileges (which fields within those records are visible). The database enforces the boundary either way.
GDPR and CCPA's concrete database-level obligations
A privacy policy is a promise. A database schema is where that promise gets kept or broken. GDPR and CCPA both say, in plain terms, that personal data has to be stored, accessed, restricted, and deleted in specific ways, and those requirements don't stop at the legal team's desk. They land on whoever owns the database, because a policy document can say whatever it wants about who's allowed to see a customer's email address. If the database grants that access to twelve roles that don't need it, the policy is fiction.
EnterpriseDB frames this well: the core Postgres features that matter for compliance are access controls, auditing, and encryption. Compliance, at the database layer, is a configuration problem. You either set the permissions correctly or you didn't.
Plenty of companies deal with both regimes at once. The good news is that the technical controls overlap a lot. Access restriction, audit trails, and data deletion satisfy both regulations in most practical cases, so a well-configured Postgres environment doesn't need two separate compliance stacks. It needs one that's built correctly.
When Postgres environments fail audits, the cause is rarely a weak policy. It's usually one of three things: access that's too permissive, audit logs that don't exist, or personal data that should have been deleted and wasn't. The rest of this piece walks through the specific Postgres features that close each of those gaps.
Postgres role-based access control and the principle of least privilege
Least privilege is one of those phrases that sounds obvious until you try to enforce it. A role architecture does.
Postgres handles this through role-based access control, or RBAC, which lets administrators define exactly who can touch specific data and what they're allowed to do with it once they get there. Liquibase lists RBAC alongside row-level security as the first practice in any serious Postgres compliance program, which tells you how foundational this layer is meant to be.
Authentication matters here too. The authentication method decides who gets through the door. RBAC decides what they can do once they're inside.
In practice, this looks like defining roles around job function rather than around individual people: a read-only analyst role, a support-agent role, an engineering role, each scoped to the tables and schemas it actually needs and nothing more. The fix is to keep the hierarchy shallow and tied to function, not to individuals, so growth means more people in existing buckets rather than more buckets.
Internal tools are where this starts to matter. Setting up RBAC correctly at the database is the hard, necessary part, but it doesn't automatically mean a support rep or a sales manager can go answer their own question without pinging an engineer first. Platforms like Basedash sit on top of that foundation and give business users a governed way to query and view data without ever touching raw database credentials or asking for a one-off privilege bump. The database still enforces the rule. The interface just makes the rule usable by people who don't write SQL.
Row-level security as a defense-in-depth control for data isolation
RBAC answers "which tables and columns can this person reach." It doesn't answer "which specific rows," a question that matters enormously in multi-tenant systems or anywhere a table mixes together data that different users should never see side by side. Row-level security, or RLS, is the Postgres feature built to answer it.
The reason RLS deserves its own layer instead of being folded into RBAC comes down to where it's enforced. Secoda describes this as data isolation for multi-tenant or role-specific scenarios: one tenant's data appears in another tenant's results when application logic somewhere forgets to add a WHERE clause, and RLS is designed to prevent exactly that failure mode.
Postgres makes RLS safe by default, which is worth understanding clearly because it's counterintuitive. Turn on row-level security for a table: if no policy has been written yet, the default behavior is total denial. No rows are visible, no rows are modifiable, for anyone. Access only opens up once a policy explicitly grants it. Here, forgetting to write a policy leaves it locked.
A common pattern for multi-tenant products is a tenant_id column paired with a policy that filters by whatever tenant the current session belongs to. Connection-pooled architectures, where many app servers share database connections, can still make this work by having RLS policies reference session variables (something like current_setting for the current tenant), so per-user row isolation survives even when connections are being reused across requests.
The misconfiguration to watch for is BYPASSRLS. Superusers, along with any role explicitly granted the BYPASSRLS attribute, skip row security entirely, no exceptions. If a role has BYPASSRLS in production, the row-level policies protecting every other role don't matter for that one account.
RLS doesn't replace RBAC. Because that enforcement happens at the engine level, it also doesn't matter whether the thing querying the database is a BI dashboard, an internal admin panel, or a raw SQL client; whatever sits on top of Postgres inherits the same row-level rules automatically, so compliance doesn't depend on every single tool being configured correctly on its own.
Column-level privileges for protecting specific sensitive fields
Rows and roles solve two problems. There's a third one left: a single table often holds both ordinary operational fields and a handful of genuinely sensitive ones, and most roles that need the former don't need the latter. Column-level privileges in Postgres let an administrator grant access to a row while withholding specific columns inside it.
The clearest example is a support team. EnterpriseDB describes a related capability, data redaction, which obscures sensitive fields like PII so they're invisible to unauthorized users or applications while the rest of the table stays fully usable for operational work.
Liquibase frames this as part of a single idea: granular access management across tables, columns, and rows together, so that users only ever reach the data they're cleared to see. Column privileges keep them away from the wrong fields inside rows they're otherwise allowed to see. Together, they shrink the blast radius of almost any access mistake, whether that mistake is a misconfigured tool, a compromised account, or a support agent who's simply curious about something they shouldn't be looking at.
None of this proves anything on its own, though. A database can have perfect RBAC, airtight RLS, and precise column grants, and still fail an audit if nobody can show evidence that the controls were actually working during the period in question. That's where logging comes in.
Audit logging with pgaudit: building the access trail that regulators expect
Access controls stop the wrong people from reaching data. They don't prove, after the fact, that the controls did their job. Regulators asking about GDPR or CCPA compliance want a record: who accessed what, and when. Without that record, a database can be configured flawlessly and still fail to demonstrate compliance, because demonstrating it requires evidence, not just a correct setup.
The timing problem here is unforgiving. The only fix is turning logging on before anyone asks for it, not after.
pgaudit is the extension built for this. It's open-source, it adds session-level and object-level audit logging on par with what proprietary enterprise databases offer out of the box, and it can record who touched which tables and when, without necessarily logging the actual content of what was accessed. It's maintained by the pgaudit open-source organization, with notable contributions from David Steele at Crunchy Data, and it runs on AWS RDS, Aurora, Supabase, Neon, and Azure. That list matters because it covers most of the managed Postgres environments organizations are actually running today, so adopting it usually doesn't mean switching infrastructure.
Turning on pgaudit badly is almost as bad as leaving it off. The better approach is to scope the log parameter to what actually matters: typically DDL changes, role changes, and read access on tables that hold sensitive data. Liquibase names this kind of logging, alongside broader monitoring, as a core piece of any serious Postgres compliance program, and for good reason: it's the evidence layer that makes every other control provable.
Auditors lean on this layer most directly. Those are all things an auditor can check directly, by querying the database's own catalogs and its audit logs, which makes the database itself the primary evidence for whether compliance claims hold up.
Encryption at rest and in transit: where Postgres native features end and infrastructure begins
Encryption is a baseline expectation under both GDPR and CCPA, and "encrypt the data" means managing a stack with at least three distinct layers, each protecting against a different kind of exposure: SSL/TLS for data moving between a client and the database, disk-level encryption for data sitting on storage, and pgcrypto for encrypting specific fields at the application level. Treating any one of these as sufficient on its own leaves the other threat models uncovered.
Responsibility for encryption splits by layer, not as one checkbox to tick but as separate obligations assigned to whoever controls each part of the stack. SSL/TLS for connections should be mandatory, full stop; turning it off for a performance bump isn't a reasonable trade-off, it's a compliance gap, because data moving unencrypted between the application and the database is exposed to anyone positioned to intercept it. Field-level encryption through pgcrypto covers a narrower, more specific case: columns holding data sensitive enough that even database administrators shouldn't be able to read it in plaintext. That's a deliberate, selective tool, not something applied broadly across a schema.
There's a geographic dimension to this too, and it's worth a brief mention even though it goes beyond what this piece covers in depth. A column encrypted perfectly in a database that's replicating to the wrong jurisdiction hasn't solved the underlying compliance problem. Encryption protects how data is stored. It doesn't answer where that data is allowed to live in the first place, and that's a separate question organizations still have to resolve.
Implementing the right to erasure without breaking referential integrity
GDPR Article 17 gives people the right to have their personal data erased, and this is one of the most commonly misimplemented requirements in any production database. The instinct a lot of teams reach for is a soft-delete flag: flip is_deleted to true, hide the row from the application, call it done. That doesn't satisfy the obligation. The data is still sitting in the database, fully intact, readable by anyone with the right query. Deactivating an account at the application layer leaves the underlying personal data exactly where it was.
What actually works is anonymization rather than physical deletion. A correctly anonymized row has that same field replaced with something that carries no trace back to the original person at all, while the row's ID and its relationships to orders, support tickets, or whatever else references it remain exactly as they were.
This distinction matters because of what GDPR actually requires. The regulation treats anonymized data as no longer personal data once a person can't be re-identified through any means reasonably likely to be used, by the original organization or by anyone else, across every copy of that data anywhere it exists. That's a real threshold to hit. The replacement value has to genuinely sever the connection to the person, not just obscure it behind a layer that a motivated party could reverse.
The deeper reason this approach exists at all comes down to referential integrity. Anonymizing in place sidesteps that problem entirely: the row stays where every other table expects to find it, the relationships hold, and the personal information inside it is gone in every sense that matters legally.
Anonymizing personal data in development and staging environments
Erasure obligations don't stop at the production database's edge. A surprising number of organizations copy real production data, including real customer PII, straight into development and staging environments, and that's a GDPR violation independent of anything happening in production. Engineers poking around in a staging database that contains real customer records are processing personal data, and that processing needs its own lawful basis, which most dev and staging setups were never built to provide.
The PostgreSQL Anonymizer extension exists to close that gap without turning anonymization into a side project maintained separately from the schema itself. It declares masking rules directly inside the database using Postgres's native SECURITY LABEL mechanism. Those rules travel along with the schema whenever it's exported through pg_dump and get version-controlled right alongside the rest of the database's DDL. Secoda describes it in similar terms: a database extension that swaps sensitive values for anonymized ones, letting teams use real-shaped data for development, testing, and analytics without ever exposing the real thing, which lines up directly with what GDPR requires for keeping confidential information inside secure environments.
The anon extension, built by Dalibo, is the specific tool behind this and supports three distinct modes. Static masking rewrites the data in place, permanently. Masked dumps, through a tool called pg_dump_anon, generate a fully sanitized copy of the database that's safe to hand to a staging environment or a new engineer, with no real customer data leaving production.
The throughline across role-based access, row-level security, column privileges, audit logging, encryption, and anonymization is the same idea stated six different ways: GDPR and CCPA are asking for a database that enforces its own rules, proves it did, and leaves nothing behind that shouldn't be there.


