DEV Community

Cover image for Auditing in Java systems: RLS in the database or application-level control?
Lucas Reis
Lucas Reis

Posted on

Auditing in Java systems: RLS in the database or application-level control?

This question comes up in every project dealing with sensitive data. And the honest answer is: it depends — but there are clear criteria to decide.

Let me get straight to the point.

What is RLS-based auditing?

Row Level Security is a native PostgreSQL feature that filters and restricts row access directly in the database, using declarative policies.

-- Enable RLS on the table
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE transactions FORCE ROW LEVEL SECURITY;

-- Policy: user only sees records from their own company
CREATE POLICY tenant_isolation ON transactions
  USING (company_id = current_setting('app.tenant_id')::uuid);

-- Audit trigger that captures session context
CREATE OR REPLACE FUNCTION fn_audit()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO audit_log (table_name, operation, user_id, data_before, data_after)
  VALUES (
    TG_TABLE_NAME,
    TG_OP,
    current_setting('app.user_id', true)::uuid,
    CASE WHEN TG_OP = 'DELETE' THEN to_jsonb(OLD) END,
    CASE WHEN TG_OP != 'DELETE' THEN to_jsonb(NEW) END
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Enter fullscreen mode Exit fullscreen mode

Auditing happens at the database level. It doesn’t matter where the query came from — psql, DBeaver, the application — the trigger logs everything.

What is application-level control with Spring?

Here, the responsibility for filtering and auditing sits in Java code, typically using Spring Security + AOP or interceptors.

// Interceptor that injects the tenant into context
@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String tenantId = request.getHeader("X-Tenant-ID");
        TenantContext.set(tenantId); // ThreadLocal
        return true;
    }

    @Override
    public void afterCompletion(...) {
        TenantContext.clear(); // Prevents leaking between requests
    }
}

// Repository that explicitly applies the filter
@Repository
public class TransactionRepository {

    public List<Transaction> findAll() {
        String tenantId = TenantContext.get(); // easy to forget this
        return entityManager
            .createQuery("SELECT t FROM Transaction t WHERE t.companyId = :tenantId")
            .setParameter("tenantId", tenantId)
            .getResultList();
    }
}

// Audit aspect via AOP
@Aspect
@Component
public class AuditAspect {

    @AfterReturning(
        pointcut = "@annotation(Auditable)",
        returning = "result"
    )
    public void audit(JoinPoint jp, Object result) {
        auditService.record(
            jp.getSignature().getName(),
            TenantContext.get(),
            SecurityContextHolder.getContext().getAuthentication().getName()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Works well — as long as nobody forgets to apply the filter.

The blind spot of each approach

With RLS, the risk is SET LOCAL outside a transaction — in connection pools like HikariCP, a plain SET without a transaction can leak between requests:


// WRONG — leaks in the connection pool
jdbcTemplate.execute("SET app.tenant_id = '" + tenantId + "'");

// CORRECT — scoped to the transaction
@Transactional
public List<Transaction> fetch(String tenantId) {
    jdbcTemplate.execute("SET LOCAL app.tenant_id = '" + tenantId + "'");
    return transactionRepository.findAll();
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

If the data is sensitive (financial, healthcare, legal), don’t rely solely on the application layer. Use RLS as an additional security layer and triggers for immutable auditing.
If your business rules are complex and the team is small, start with application-level control — but document the risks and plan to move tenant isolation to the database as the system scales.
The application can have bugs. The database needs to be the last barrier.

Top comments (0)