<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex Serban</title>
    <description>The latest articles on DEV Community by Alex Serban (@alex_serban).</description>
    <link>https://dev.to/alex_serban</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3932555%2F3b55ffae-761d-4f6f-a057-2a510a9788b9.jpg</url>
      <title>DEV Community: Alex Serban</title>
      <link>https://dev.to/alex_serban</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alex_serban"/>
    <language>en</language>
    <item>
      <title>If someone asked you who accessed your production database last Tuesday, could you answer?</title>
      <dc:creator>Alex Serban</dc:creator>
      <pubDate>Tue, 19 May 2026 06:35:45 +0000</pubDate>
      <link>https://dev.to/alex_serban/if-someone-asked-you-who-accessed-your-production-database-last-tuesday-could-you-answer-1nip</link>
      <guid>https://dev.to/alex_serban/if-someone-asked-you-who-accessed-your-production-database-last-tuesday-could-you-answer-1nip</guid>
      <description>&lt;p&gt;Two scenarios.&lt;/p&gt;

&lt;p&gt;Your biggest prospect sends over a security questionnaire. &lt;br&gt;
Question 47: "Can you provide per-user audit trails for all production database access?" Or a customer emails saying they think their account may have been accessed by someone who shouldn't have had access. You have 24 hours to respond.&lt;/p&gt;

&lt;p&gt;Both questions come down to the same thing: who ran what query, and when?&lt;/p&gt;

&lt;p&gt;Most teams can't answer that. Not because they have no logs because their logs don't answer that question.&lt;/p&gt;

&lt;p&gt;In March 2026, Italy's data protection authority fined Intesa Sanpaolo €31.8M. Not a breach, not data sold one employee ran 6,637 queries across 3,573 customer records over two years, no access controls stopped them, and no anomaly detection fired. Query logs existed. Per-user attribution didn't. When the regulator asked who ran those queries, the bank couldn't say.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shared credentials problem
&lt;/h2&gt;

&lt;p&gt;Most engineering teams handle production database access the same way. One &lt;code&gt;readonly&lt;/code&gt; role. Credentials in a 1Password vault or a &lt;code&gt;.env.production&lt;/code&gt; in secrets manager. Shared across everyone who needs it.&lt;/p&gt;

&lt;p&gt;It works until it doesn't. A few situations where it bites:&lt;/p&gt;

&lt;p&gt;An enterprise prospect asks for SOC 2 evidence. CC6.1 and CC7.2 require logical access controls and monitoring of system activity. Your answer: "We use a shared readonly role with pg_audit logging." Their security team: "We need per-user attribution, not role-level attribution." Deal paused.&lt;/p&gt;

&lt;p&gt;A regulator or customer's lawyer asks about a specific record. "Show me every access to this customer's account in March." Your pg_audit logs show &lt;code&gt;readonly_user&lt;/code&gt; ran a SELECT on the &lt;code&gt;users&lt;/code&gt; table. You have 15 engineers sharing that role. You cannot answer the question.&lt;/p&gt;

&lt;p&gt;An engineer leaves under bad terms. You rotate the shared credential or you don't, because rotating means hunting down every CI pipeline, every &lt;code&gt;.env&lt;/code&gt;, every tunnel they might have set up. And even if you do rotate it, you still have no idea what they accessed in their last two weeks. The logs show &lt;code&gt;readonly_user&lt;/code&gt; and nothing else.&lt;/p&gt;
&lt;h2&gt;
  
  
  What pg_audit actually gives you
&lt;/h2&gt;

&lt;p&gt;pg_audit is a solid extension. You should have it. Here's a typical log entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;03&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="n"&gt;UTC&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;12847&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;LOG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;AUDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"SELECT id, email, created_at FROM users WHERE id = $1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;logged&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the role, the statement type, the object accessed, the full query text, and the timestamp.&lt;/p&gt;

&lt;p&gt;What you don't get: which human ran this, whether they were authorized to access that table, or which fields came back.&lt;/p&gt;

&lt;p&gt;If every engineer has their own PostgreSQL role, pg_audit gives you genuine per-user attribution. If they share a role, you get role-level attribution. You can prove &lt;code&gt;readonly_user&lt;/code&gt; ran a query. You can't prove it was Alex from the backend team, or that Alex's access was authorized at that time.&lt;/p&gt;

&lt;p&gt;pg_audit is necessary. On a shared role, it's not sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Teleport and Boundary give you
&lt;/h2&gt;

&lt;p&gt;Teleport and Boundary solve the attribution problem at the session level. Each engineer authenticates with their own identity, that identity gets attached to the database session, the session is recorded. A real step up from shared credentials.&lt;/p&gt;

&lt;p&gt;The limits are worth understanding. Both are perimeter-based they secure the path through the approved tunnel. If a credential exists anywhere else (a CI variable with a hardcoded connection string, a developer's local &lt;code&gt;.env&lt;/code&gt; from before Teleport was rolled out, an old bastion rule someone forgot to clean up) that path is wide open.&lt;/p&gt;

&lt;p&gt;Session recordings also create volume. To answer "which queries touched this customer record in March," you're either replaying recordings or building a search layer on top. For a regulator audit or a subject access request, being able to query logs directly beats replaying session recordings one by one, that's a real problem.&lt;/p&gt;

&lt;p&gt;Both tools are worth having. They don't close the field-level problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field-level gap nobody talks about
&lt;/h2&gt;

&lt;p&gt;An engineer debugging a billing issue runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns &lt;code&gt;customer_id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;plan_tier&lt;/code&gt; and also &lt;code&gt;iban&lt;/code&gt;, &lt;code&gt;date_of_birth&lt;/code&gt;, &lt;code&gt;ssn&lt;/code&gt;. The engineer needed the first three. They got all six. The billing bug didn't require those fields. Returning them is a data minimization risk under GDPR Art. 5(1)(c), whether or not the access was logged.&lt;/p&gt;

&lt;p&gt;PostgreSQL column-level privileges exist but break down with CTEs, subqueries, and joins in ways that are easy to misconfigure. The bigger issue is that most teams have never applied field-level controls to internal database access at all. They applied data minimization to their external APIs and assumed internal access was different.&lt;/p&gt;

&lt;p&gt;The regulation doesn't make that distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  An architecture that closes all three gaps
&lt;/h2&gt;

&lt;p&gt;The fix that actually works: engineers don't connect directly to production. This sounds annoying until you see what it enables.&lt;/p&gt;

&lt;p&gt;Instead, they call named, typed query functions. &lt;code&gt;get_order_by_id(order_id)&lt;/code&gt;. &lt;code&gt;search_customers(email_prefix)&lt;/code&gt;. Those functions run in a policy evaluation layer between the engineer and the database. Before the query reaches Postgres, it attaches user identity from the auth context, evaluates field-level policies (does this user's role permit access to &lt;code&gt;iban&lt;/code&gt; fields here? if not, mask or reject), and writes to an append-only audit table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;function_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rows_returned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_policies_applied&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;called_at&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'usr_abc123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alex@company.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'get_order_by_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'{"order_id": "ord_xyz789"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'{"iban": "masked", "ssn": "masked"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'2026-03-15T14:23:11Z'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit table is INSERT-only. No UPDATE, no DELETE. When Alex leaves, you revoke their identity in one place. No credential rotation across 12 pipelines.&lt;/p&gt;

&lt;p&gt;How the options compare:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;Per-user attribution&lt;/th&gt;
&lt;th&gt;Field-level control&lt;/th&gt;
&lt;th&gt;Revocable on offboarding&lt;/th&gt;
&lt;th&gt;Immutable log&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shared DB role&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg_audit (shared role)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teleport / Boundary&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application-layer proxy&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can build this yourself. The pieces exist in the Postgres ecosystem. Whether it's worth the build time and maintenance is a separate question.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is what &lt;a href="https://scalple.com" rel="noopener noreferrer"&gt;Scalple&lt;/a&gt; implements, free for teams under 15 users. Either waycan you answer "who accessed that record and when" before someone asks?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>database</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
