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;
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()
);
}
}
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();
}
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)