Tất cả bài viết

Soft delete leak — data đã xóa vẫn lọt ra API

@Where giúp nhưng native query, JOIN thiếu filter, và admin export quên deleted_at — patient thấy appointment đã hủy. Cách audit và phòng leak.

soft-deletesecuritydata-leakhibernate

Bài 57: soft delete = thêm deleted_at, mọi query phải lọc. Team thêm @Where(clause = "deleted_at IS NULL") lên Appointment — tưởng xong.

QA báo: patient mở app vẫn thấy lịch đã hủy tuần trước. Dev grep: repository đúng hết. Bug nằm ở report SQL nativeJOIN doctor không filter@Where không áp vào đó.

Đó là soft delete leak: row vẫn trong DB (đúng soft delete), nhưng lọt ra boundary user không được phép thấy.


@Where không phải ma thuật toàn cục

Hibernate @Where chỉ inject cho entity load qua ORM path. Không tự sửa:

@Query(value = """
    SELECT a.* FROM appointment a
    JOIN patient p ON a.patient_id = p.id
    WHERE p.clinic_id = :clinicId
    """, nativeQuery = true)
List<Appointment> findByClinicNative(UUID clinicId);
// ❌ Thiếu AND a.deleted_at IS NULL
@Query("""
    SELECT d FROM Doctor d
    JOIN d.appointments a
    WHERE a.patient.id = :patientId
    """)
List<Doctor> findDoctorsWithAppointments(UUID patientId);
// ❌ Filter trên Appointment có thể không áp như mày tưởng — test thật

Rule: mỗi query mới — code review hỏi deleted_at. Không assume annotation cứu.


JPQL an toàn hơn — pattern repository

public interface AppointmentRepository extends JpaRepository<Appointment, UUID> {

  @Query("SELECT a FROM Appointment a WHERE a.patient.id = :patientId AND a.deletedAt IS NULL")
  List<Appointment> findActiveByPatientId(UUID patientId);

  // Admin xem cả đã xóa — tách method, không reuse nhầm
  @Query("SELECT a FROM Appointment a WHERE a.patient.id = :patientId")
  List<Appointment> findAllIncludingDeletedByPatientId(UUID patientId);
}

Tên method findActive* vs findAllIncluding* — caller khó gọi nhầm hơn overload cùng tên.


Global filter (Hibernate 6)

@SQLRestriction("deleted_at IS NULL") trên entity (thay @Where deprecated) — vẫn cần native query tự lọc. @FilterDef + @Filter cho admin session bật tắt — advanced, document rõ khi enable.


Leak qua relation và cache

// Patient load appointment list qua cascade — verify collection filter
@OneToMany(mappedBy = "patient")
private List<Appointment> appointments; // phải là active-only hoặc load qua repo

Redis cache key appointments:patient:{id} — cancel appointment mà không invalidate → user thấy bản cũ (bài 88 territory). Soft delete leak không chỉ SQL.


API và authorization

@GetMapping("/api/appointments/{id}")
public AppointmentResponse get(@PathVariable UUID id) {
  return appointmentService.getActiveForCurrentUser(id);
}

public AppointmentResponse getActiveForCurrentUser(UUID id) {
  var appointment = appointmentRepository.findActiveByIdAndPatientId(id, userContext.getPatientId())
      .orElseThrow(() -> new NotFoundException("APPOINTMENT_NOT_FOUND"));
  // 404 thay vì 403 — không tiết lộ id tồn tại nhưng đã xóa
  return mapper.toResponse(appointment);
}

findById thuần → deleted row → 200 với data = leak + GDPR headache.


Audit định kỳ

Script staging/prod read-only:

-- Appointment soft-deleted nhưng vẫn xuất hiện trong view báo cáo?
SELECT a.id FROM appointment a
WHERE a.deleted_at IS NOT NULL
  AND EXISTS (
    SELECT 1 FROM upcoming_appointments_view v WHERE v.appointment_id = a.id
  );

View/materialized report phải sync definition khi thêm soft delete.


Khi nào hard delete hoặc purge

Retention policy: sau 6 tháng hard delete row + anonymize audit (bài 57). Giảm surface leak và DB phình.


Takeaway

Sau khi bật soft delete: grep nativeQuery, JOIN, cache key, export CSV. Một query thiếu deleted_at IS NULL = leak. Đặt tên API/repository phân biệt Active vs IncludingDeleted — đừng trông chờ @Where một mình canh cả codebase.


Bài tiếp theo: (tiếp series Production & Ops hoặc case study HMS)