Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 116 additions & 13 deletions app/src/main/groovy/se/alipsa/accounting/service/AuditLogService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,15 @@ final class AuditLogService {

private static List<String> validateIntegrityForCompany(Sql sql, long companyId) {
List<String> problems = []
// Archiving a fiscal year (see FiscalYearReplacementService.archiveFiscalYearAuditLogRows)
// resets the chain head to the latest *live* row so the next insert skips over the rows
// being archived, rather than chaining off them. So the row immediately following an
// archival deliberately does not continue the raw by-id sequence; it jumps back to the
// last live row instead. expectedPreviousHash tracks the raw sequence (what a row chains
// from under normal, non-archival inserts); expectedLastLiveHash tracks that skip target.
String expectedPreviousHash = null
String actualLastHash = null
String expectedLastLiveHash = null
String actualLastLiveHash = null
sql.rows('''
select id,
event_type as eventType,
Expand All @@ -172,13 +179,15 @@ final class AuditLogService {
details,
previous_hash as previousHash,
entry_hash as entryHash,
created_at as createdAt
created_at as createdAt,
archived
from audit_log
where company_id = ?
order by id
''', [companyId]).each { GroovyRowResult row ->
AuditLogEntry entry = mapEntry(row)
if (entry.previousHash != expectedPreviousHash) {
boolean archived = row.get('archived') as Boolean
if (entry.previousHash != expectedPreviousHash && entry.previousHash != expectedLastLiveHash) {
problems << ("Auditlogg ${entry.id} har fel föregående hash." as String)
}
String calculated = calculateHash(new AuditEntrySeed(
Expand All @@ -198,12 +207,15 @@ final class AuditLogService {
problems << ("Auditlogg ${entry.id} har ogiltig hash." as String)
}
expectedPreviousHash = entry.entryHash
actualLastHash = entry.entryHash
if (!archived) {
expectedLastLiveHash = entry.entryHash
actualLastLiveHash = entry.entryHash
}
}
AuditChainHead chainHead = loadChainHead(sql, companyId)
if (chainHead == null) {
problems << 'Auditloggkedjans huvud saknas.'
} else if (chainHead.lastEntryHash != actualLastHash) {
} else if (chainHead.lastEntryHash != actualLastLiveHash) {
problems << 'Auditloggkedjans huvud pekar inte på sista auditraden.'
}
problems
Expand Down Expand Up @@ -451,14 +463,23 @@ final class AuditLogService {

@PackageScope
static void rebuildIntegrityChain(Sql sql, long companyId) {
String previousHash = null
// Mirrors the live write path (see validateIntegrityForCompany): a live row chains from
// the latest *live* row, skipping over any archived rows in between - the same skip that
// archiveFiscalYearAuditLogRows()'s chain-head reset produces on insert. An archived row
// chains from whichever row immediately precedes it, since that is what it actually
// chained from back when it was itself still live. Collapsing both cases onto a single
// "previous row by id" chain (as an earlier version of this method did) makes a live row
// downstream of an archived block chain through it, which validateIntegrityForCompany
// does not treat as equivalent to a real chain-head-reset skip.
String lastLiveHash = null
sql.withBatch('''
update audit_log
set previous_hash = ?,
entry_hash = ?
where id = ?
''') { groovy.sql.BatchingPreparedStatementWrapper statement ->
String batchPreviousHash = null
String rawPreviousHash = null
String liveHash = null
sql.rows('''
select id,
event_type as eventType,
Expand All @@ -470,11 +491,14 @@ final class AuditLogService {
actor,
summary,
details,
created_at as createdAt
created_at as createdAt,
archived
from audit_log
where company_id = ?
order by id
''', [companyId]).each { GroovyRowResult row ->
boolean archived = row.get('archived') as Boolean
String previousHash = archived ? rawPreviousHash : liveHash
String entryHash = calculateHash(new AuditEntrySeed(
eventType: row.get('eventType') as String,
voucherId: longOrNull(row.get('voucherId')),
Expand All @@ -485,15 +509,94 @@ final class AuditLogService {
actor: row.get('actor') as String,
summary: row.get('summary') as String,
details: SqlValueMapper.toClob(row.get('details')),
previousHash: batchPreviousHash,
previousHash: previousHash,
createdAt: SqlValueMapper.toLocalDateTime(row.get('createdAt'))
))
statement.addBatch([batchPreviousHash, entryHash, row.get('id')])
batchPreviousHash = entryHash
statement.addBatch([previousHash, entryHash, row.get('id')])
rawPreviousHash = entryHash
if (!archived) {
liveHash = entryHash
}
}
lastLiveHash = liveHash
}
updateChainHead(sql, companyId, lastLiveHash)
}

/**
* One-time repair for databases affected by the pre-V27 archival bug (see
* V27__audit_log_decouple_references.sql): archiving used to null out all five reference
* columns without recalculating entry_hash. Best-effort restores whatever is recoverable
* from each row's own (untouched) details text, then rebuilds the hash chain for any
* company that still fails validation afterwards - closing out rows where the original
* value isn't recoverable (e.g. CANCEL_VOUCHER/CORRECTION_VOUCHER never recorded
* fiscalYearId in details in the first place).
*/
@PackageScope
static void repairIntegrityForAllCompanies(Sql sql) {
repairArchivedReferencesFromDetails(sql)
sql.rows('select distinct company_id as companyId from audit_log').each { GroovyRowResult row ->
long companyId = ((Number) row.get('companyId')).longValue()
if (!validateIntegrityForCompany(sql, companyId).isEmpty()) {
rebuildIntegrityChain(sql, companyId)
}
}
}

@PackageScope
static void repairArchivedReferencesFromDetails(Sql sql) {
sql.rows('''
select id,
voucher_id as voucherId,
attachment_id as attachmentId,
fiscal_year_id as fiscalYearId,
accounting_period_id as accountingPeriodId,
vat_period_id as vatPeriodId,
details
from audit_log
where archived = true
and details is not null
and (voucher_id is null
or attachment_id is null
or fiscal_year_id is null
or accounting_period_id is null
or vat_period_id is null)
''').each { GroovyRowResult row ->
Map<String, String> parsed = parseDetails(SqlValueMapper.toClob(row.get('details')))
if (parsed.isEmpty()) {
return
}
// transferVoucherId is VAT_PERIOD_LOCKED's name for what recordEvent() stored as
// voucher_id - see recordVatPeriodLocked().
Long voucherId = longOrNull(row.get('voucherId')) ?: longOrNull(parsed.voucherId) ?: longOrNull(parsed.transferVoucherId)
Long attachmentId = longOrNull(row.get('attachmentId')) ?: longOrNull(parsed.attachmentId)
Long fiscalYearId = longOrNull(row.get('fiscalYearId')) ?: longOrNull(parsed.fiscalYearId)
Long accountingPeriodId = longOrNull(row.get('accountingPeriodId')) ?: longOrNull(parsed.accountingPeriodId)
Long vatPeriodId = longOrNull(row.get('vatPeriodId')) ?: longOrNull(parsed.vatPeriodId)
sql.executeUpdate('''
update audit_log
set voucher_id = ?,
attachment_id = ?,
fiscal_year_id = ?,
accounting_period_id = ?,
vat_period_id = ?
where id = ?
''', [voucherId, attachmentId, fiscalYearId, accountingPeriodId, vatPeriodId, row.get('id')])
}
}

private static Map<String, String> parseDetails(String details) {
Map<String, String> parsed = [:]
if (!details) {
return parsed
}
details.split('\n').each { String line ->
int separatorIndex = line.indexOf('=')
if (separatorIndex > 0) {
parsed[line.substring(0, separatorIndex)] = line.substring(separatorIndex + 1)
}
previousHash = batchPreviousHash
}
updateChainHead(sql, companyId, previousHash)
parsed
}

private AuditLogEntry recordStandaloneEvent(String eventType, String summary, String details, long companyId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ final class DatabaseService {
new MigrationDefinition(23, 'V23__attachment_status.sql', '/db/migrations/V23__attachment_status.sql'),
new MigrationDefinition(24, 'V24__voucher_navigation_index.sql', '/db/migrations/V24__voucher_navigation_index.sql'),
new MigrationDefinition(25, 'V25__company_accounting_method.sql', '/db/migrations/V25__company_accounting_method.sql'),
new MigrationDefinition(26, 'V26__accounting_instructions.sql', '/db/migrations/V26__accounting_instructions.sql')
new MigrationDefinition(26, 'V26__accounting_instructions.sql', '/db/migrations/V26__accounting_instructions.sql'),
new MigrationDefinition(27, 'V27__audit_log_decouple_references.sql', '/db/migrations/V27__audit_log_decouple_references.sql')
]

static DatabaseService newForTesting() {
Expand Down Expand Up @@ -86,8 +87,16 @@ final class DatabaseService {
AppPaths.ensureDirectoryStructure()
withTransaction { Sql sql ->
ensureSchemaVersionTable(sql)
applyPendingMigrations(sql)
List<Integer> appliedVersions = applyPendingMigrations(sql)
applyIndexes(sql)
// V27 stopped the archival bug that nulled out audit_log's reference columns without
// recalculating entry_hash, but couldn't itself repair rows already corrupted by it.
// Runs exactly once, right when a database first crosses V27, never again afterwards -
// this must stay a one-time migration step, not a general "if hash invalid, fix it"
// runtime behavior, or it would silently mask real tampering after this point too.
if (appliedVersions.contains(27)) {
AuditLogService.repairIntegrityForAllCompanies(sql)
}
}
log.info("Database initialized at ${AppPaths.databaseBasePath()}.mv.db")
}
Expand Down Expand Up @@ -178,7 +187,7 @@ final class DatabaseService {
((Number) row.total).intValue() == 1
}

private void applyPendingMigrations(Sql sql) {
private List<Integer> applyPendingMigrations(Sql sql) {
int currentVersion = currentSchemaVersion(sql)
List<MigrationDefinition> pending = MIGRATIONS.findAll { MigrationDefinition migration ->
migration.version > currentVersion
Expand All @@ -194,6 +203,7 @@ final class DatabaseService {
)
log.info("Applied migration ${migration.name}.")
}
pending.collect { MigrationDefinition migration -> migration.version }
}

private int currentSchemaVersion(Sql sql) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,14 @@ final class FiscalYearReplacementService {

@PackageScope
static void archiveFiscalYearAuditLogRows(Sql sql, long companyId, long fiscalYearId) {
// Reference columns are deliberately left untouched: entry_hash was computed from them
// at insert time and must never change afterwards, since later rows chain off it via
// previous_hash. audit_log no longer has restrictive foreign keys to voucher/attachment/
// fiscal_year/accounting_period/vat_period (see V27), so these rows are free to keep
// pointing at records that the subsequent purge deletes.
sql.executeUpdate('''
update audit_log
set archived = true,
fiscal_year_id = null,
accounting_period_id = null,
vat_period_id = null,
voucher_id = null,
attachment_id = null
set archived = true
where company_id = ?
and archived = false
and (
Expand Down
Loading