| 1 | package com.rbctcsworld.ecommerce.audit; | |
| 2 | ||
| 3 | import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException; | |
| 4 | import jakarta.persistence.EntityManager; | |
| 5 | import org.springframework.jdbc.core.JdbcTemplate; | |
| 6 | import org.springframework.jdbc.core.RowMapper; | |
| 7 | import org.springframework.stereotype.Service; | |
| 8 | import org.springframework.transaction.annotation.Transactional; | |
| 9 | import org.springframework.transaction.event.TransactionPhase; | |
| 10 | import org.springframework.transaction.event.TransactionalEventListener; | |
| 11 | ||
| 12 | import java.time.Clock; | |
| 13 | import java.time.LocalDateTime; | |
| 14 | import java.time.temporal.ChronoUnit; | |
| 15 | import java.util.ArrayList; | |
| 16 | import java.util.List; | |
| 17 | import java.util.Locale; | |
| 18 | ||
| 19 | /** | |
| 20 | * Writes and reads the audit trail (audit_log). | |
| 21 | * | |
| 22 | * Writing: services publish an AuditEvent inside their transaction. Just before that transaction commits, | |
| 23 | * this listener first flushes the pending entity changes (so every row lock the transaction needs is already | |
| 24 | * taken), then locks the chain head, appends one row per changed field and moves the head. The head is the | |
| 25 | * LAST lock any transaction takes, so audited transactions cannot deadlock on it. If writing fails, the | |
| 26 | * whole transaction rolls back: no change without its audit row. | |
| 27 | * | |
| 28 | * Reading (store admin): filter, verify the hash chain, export as CSV. | |
| 29 | */ | |
| 30 | @Service | |
| 31 | public class AuditService { | |
| 32 | ||
| 33 | public static final int DEFAULT_LIMIT = 200; | |
| 34 | public static final int MAX_LIMIT = 1000; | |
| 35 | ||
| 36 | private static final RowMapper<AuditRecord> ROW = (rs, i) -> new AuditRecord(rs.getLong("seq"), | |
| 37 | rs.getTimestamp("occurred_at").toLocalDateTime(), rs.getString("actor"), rs.getString("entity_type"), | |
| 38 | rs.getLong("entity_id"), rs.getString("action"), rs.getString("field"), rs.getString("old_value"), | |
| 39 | rs.getString("new_value"), rs.getString("prev_hash"), rs.getString("hash")); | |
| 40 | private static final String COLUMNS = | |
| 41 | "seq, occurred_at, actor, entity_type, entity_id, action, field, old_value, new_value, prev_hash, hash"; | |
| 42 | ||
| 43 | private final JdbcTemplate jdbc; | |
| 44 | private final EntityManager em; | |
| 45 | private final Clock clock; | |
| 46 | ||
| 47 | public AuditService(JdbcTemplate jdbc, EntityManager em, Clock clock) { | |
| 48 | this.jdbc = jdbc; | |
| 49 | this.em = em; | |
| 50 | this.clock = clock; | |
| 51 | } | |
| 52 | ||
| 53 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) | |
| 54 | public void on(AuditEvent event) { | |
| 55 |
1
1. on : removed conditional - replaced equality check with false → KILLED |
if (event.changes().isEmpty()) return; |
| 56 |
1
1. on : removed call to jakarta/persistence/EntityManager::flush → KILLED |
em.flush(); |
| 57 |
1
1. on : removed call to com/rbctcsworld/ecommerce/audit/AuditService::append → KILLED |
append(event.actor(), event.changes()); |
| 58 | } | |
| 59 | ||
| 60 | void append(String actor, List<AuditChange> changes) { | |
| 61 | long[] seq = new long[1]; | |
| 62 | String[] prev = new String[1]; | |
| 63 |
1
1. append : removed call to org/springframework/jdbc/core/JdbcTemplate::query → KILLED |
jdbc.query("select last_seq, last_hash from audit_head where id = 1 for update", rs -> { |
| 64 | seq[0] = rs.getLong(1); | |
| 65 | prev[0] = rs.getString(2); | |
| 66 | }); | |
| 67 |
1
1. append : removed conditional - replaced equality check with false → KILLED |
if (prev[0] == null) throw new IllegalStateException("audit_head row is missing"); |
| 68 | LocalDateTime now = LocalDateTime.now(clock).truncatedTo(ChronoUnit.MILLIS); | |
| 69 | for (AuditChange c : changes) { | |
| 70 |
1
1. append : Replaced long addition with subtraction → KILLED |
seq[0]++; |
| 71 | String hash = AuditChain.hash(prev[0], seq[0], now, actor, c.entityType(), c.entityId(), c.action(), | |
| 72 | c.field(), c.oldValue(), c.newValue()); | |
| 73 | jdbc.update("insert into audit_log (" + COLUMNS + ") values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 74 | seq[0], now, actor, c.entityType(), c.entityId(), c.action(), c.field(), c.oldValue(), | |
| 75 | c.newValue(), prev[0], hash); | |
| 76 | prev[0] = hash; | |
| 77 | } | |
| 78 | jdbc.update("update audit_head set last_seq = ?, last_hash = ? where id = 1", seq[0], prev[0]); | |
| 79 | } | |
| 80 | ||
| 81 | /** Newest first. Every filter is optional; limit 1..MAX_LIMIT (400 otherwise). */ | |
| 82 | @Transactional(readOnly = true) | |
| 83 | public List<AuditRecord> find(String entityType, Long entityId, String actor, Integer limit) { | |
| 84 |
1
1. find : removed conditional - replaced equality check with false → KILLED |
int n = limit == null ? DEFAULT_LIMIT : limit; |
| 85 |
4
1. find : removed conditional - replaced comparison check with false → KILLED 2. find : removed conditional - replaced comparison check with false → KILLED 3. find : changed conditional boundary → KILLED 4. find : changed conditional boundary → KILLED |
if (n < 1 || n > MAX_LIMIT) throw new BusinessRuleException("limit must be between 1 and " + MAX_LIMIT); |
| 86 | StringBuilder sql = new StringBuilder("select " + COLUMNS + " from audit_log where 1 = 1"); | |
| 87 | List<Object> args = new ArrayList<>(); | |
| 88 |
2
1. find : removed conditional - replaced equality check with false → KILLED 2. find : removed conditional - replaced equality check with false → KILLED |
if (entityType != null && !entityType.isBlank()) { |
| 89 | sql.append(" and entity_type = ?"); | |
| 90 | args.add(entityType.trim().toUpperCase(Locale.ROOT)); | |
| 91 | } | |
| 92 |
1
1. find : removed conditional - replaced equality check with false → KILLED |
if (entityId != null) { |
| 93 | sql.append(" and entity_id = ?"); | |
| 94 | args.add(entityId); | |
| 95 | } | |
| 96 |
2
1. find : removed conditional - replaced equality check with false → KILLED 2. find : removed conditional - replaced equality check with false → KILLED |
if (actor != null && !actor.isBlank()) { |
| 97 | sql.append(" and lower(actor) = ?"); | |
| 98 | args.add(actor.trim().toLowerCase(Locale.ROOT)); | |
| 99 | } | |
| 100 | sql.append(" order by seq desc limit ").append(n); | |
| 101 |
1
1. find : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/audit/AuditService::find → KILLED |
return jdbc.query(sql.toString(), ROW, args.toArray()); |
| 102 | } | |
| 103 | ||
| 104 | /** Recomputes the whole chain and compares its end with the head. */ | |
| 105 | @Transactional(readOnly = true) | |
| 106 | public AuditChain.Verification verify() { | |
| 107 | List<AuditRecord> rows = jdbc.query("select " + COLUMNS + " from audit_log order by seq", ROW); | |
| 108 | long[] headSeq = new long[1]; | |
| 109 | String[] headHash = new String[1]; | |
| 110 |
1
1. verify : removed call to org/springframework/jdbc/core/JdbcTemplate::query → KILLED |
jdbc.query("select last_seq, last_hash from audit_head where id = 1", rs -> { |
| 111 | headSeq[0] = rs.getLong(1); | |
| 112 | headHash[0] = rs.getString(2); | |
| 113 | }); | |
| 114 |
1
1. verify : replaced return value with null for com/rbctcsworld/ecommerce/audit/AuditService::verify → KILLED |
return AuditChain.verify(rows, headSeq[0], headHash[0]); |
| 115 | } | |
| 116 | ||
| 117 | /** The filtered rows as CSV (oldest first), safe to open in a spreadsheet. */ | |
| 118 | @Transactional(readOnly = true) | |
| 119 | public String csv(String entityType, Long entityId, String actor) { | |
| 120 | List<AuditRecord> rows = new ArrayList<>(find(entityType, entityId, actor, MAX_LIMIT)); | |
| 121 |
1
1. csv : removed call to java/util/Collections::reverse → KILLED |
java.util.Collections.reverse(rows); |
| 122 | StringBuilder s = new StringBuilder("seq,occurred_at,actor,entity_type,entity_id,action,field,old_value,new_value,hash\r\n"); | |
| 123 | for (AuditRecord r : rows) { | |
| 124 | s.append(r.seq()).append(',').append(cell(r.occurredAt().toString())).append(',').append(cell(r.actor())) | |
| 125 | .append(',').append(cell(r.entityType())).append(',').append(r.entityId()).append(',') | |
| 126 | .append(cell(r.action())).append(',').append(cell(r.field())).append(',') | |
| 127 | .append(cell(r.oldValue())).append(',').append(cell(r.newValue())).append(',') | |
| 128 | .append(cell(r.hash())).append("\r\n"); | |
| 129 | } | |
| 130 |
1
1. csv : replaced return value with "" for com/rbctcsworld/ecommerce/audit/AuditService::csv → KILLED |
return s.toString(); |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * One CSV cell: quoted, inner quotes doubled. A value that a spreadsheet would run as a formula | |
| 135 | * (=, +, -, @, tab, carriage return at the start) gets a leading apostrophe - CSV injection (OWASP). | |
| 136 | */ | |
| 137 | static String cell(String value) { | |
| 138 |
1
1. cell : replaced return value with "" for com/rbctcsworld/ecommerce/audit/AuditService::cell → KILLED |
return com.rbctcsworld.ecommerce.common.Csv.cell(value); |
| 139 | } | |
| 140 | } | |
Mutations | ||
| 55 |
1.1 |
|
| 56 |
1.1 |
|
| 57 |
1.1 |
|
| 63 |
1.1 |
|
| 67 |
1.1 |
|
| 70 |
1.1 |
|
| 84 |
1.1 |
|
| 85 |
1.1 2.2 3.3 4.4 |
|
| 88 |
1.1 2.2 |
|
| 92 |
1.1 |
|
| 96 |
1.1 2.2 |
|
| 101 |
1.1 |
|
| 110 |
1.1 |
|
| 114 |
1.1 |
|
| 121 |
1.1 |
|
| 130 |
1.1 |
|
| 138 |
1.1 |