Skip to content

[#343] Tamper-evident CSV audit: construct the rotatable writer once the resumed file is verified - #355

Merged
vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-343-secure-csv-rotate-during-verify
Oct 19, 2026
Merged

vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-343-secure-csv-rotate-during-verify

Conversation

@vharseko

@vharseko vharseko commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Fixes #343.

Construction order

SecureCsvWriter constructed its RotatableWriter first, and the RotatableWriter constructor schedules the rotation and retention checks. Only then did the constructor read the keys and verify the file it resumes. A check due on the resumed file, by maxFileSize or by rotationInterval from its lastModified(), could run during the construction: it renamed the file and its keystore, and postRotationAction wrote new keys into a new keystore, through the secureStorage that the verifier reads. A valid file, already archived, then failed verification, and the topic did not start.

The writer is now constructed last, once the keys are read and the file it resumes is verified. The first check runs one check interval after the construction, on a writer that holds the keys and the last signature of the file.

A file that cannot be opened

The file is still opened, and closed at once, before the keys. A file that cannot be opened, as one in a directory that cannot be created, fails with an IOException, which CsvAuditEventHandler.startup() logs for the topic. Without that, a new file would fail first when its keystore is stored, with a SecureStorageException that the constructor wraps in a RuntimeException, and the startup would fail for every topic. Opening for append leaves the file and its lastModified() as they are.

A writer whose check interval is invalid

Found in the review. The RotatableWriter constructor opened its file before it rejected an unlimited or zero rotation and retention check interval, and the caller of a constructor that throws gets no writer to close: the file stayed open until its stream was collected. An interval under one millisecond passed that check, and failed after the open with the IllegalArgumentException of scheduleAtFixedRate, which CsvAuditEventHandler.startup() does not catch for the topic. The constructor now builds the policies and checks the interval before it opens the file, and rejects an interval under one millisecond with the same IOException. That includes a negative one: Duration adds the parts of a composite without an overflow check, and 9223372036854775807 ms, 1 ms parses to a negative interval.

Tests

  • SecureCsvWriterTest.shouldNotRotateResumedFileWhileVerifyingIt: a written file is resumed with rotation and a 50 ms check interval, with a rotation due at the first check: its size at maxFileSize, or its lastModified() two hours back with a 1 hour rotationInterval. A CommentMatcher in the CsvPreference holds the verification for 500 ms on the first line. The construction succeeds, and the first check after it rotates the file. The archive passes verification, and so does the live file, with one event after the rotation. On Windows a file that the verifier holds open cannot be renamed, and a rotation that fails to rename it still resets the time of the last rotation: there, only the rotationInterval row is sure to fail with the writer constructed first.
  • SecureCsvWriterTest.shouldFailWithIOExceptionWhenFileCannotBeOpened: a file under a regular file fails with an IOException.
  • SecureCsvWriterTest.shouldFailWithIOExceptionWhenCheckIntervalIsInvalidForNewFile: a new file with rotation and an unlimited check interval fails with the IOException of the RotatableWriter.
  • RotatableWriterTest.testRejectsInvalidRotationRetentionCheckIntervalBeforeOpeningTheFile: an unlimited, zero, 500 microseconds or overflowed 9223372036854775807 ms, 1 ms check interval fails with an IOException, and the file is not created. testRejectsInvalidRotationRetentionCheckIntervalWithRetentionOnlyBeforeOpeningTheFile does the same with a retention policy alone.
  • RotatableWriterTest.testAcceptsAnyRotationRetentionCheckIntervalWithoutPolicy: with no policy, no check is scheduled, and the same intervals are accepted. testAcceptsOneMillisecondRotationRetentionCheckInterval: one millisecond is accepted.

Measured on JDK 27:

  • With the SecureCsvWriter of master, both rows fail with "The CSV file was tampered: The file contains no CSV header": with a 50 ms interval, the first check runs while the keys are read, and renames the file before the verifier opens it. With a 1 s interval and a 3 s hold, both rows fail with the reason given in the issue, "Final HMAC key doesn't match expected value".
  • Without the open and close before the keys, shouldFailWithIOExceptionWhenFileCannotBeOpened fails with the RuntimeException, and so does CsvAuditEventHandlerTest.canConfigureCsvHandlerFromJsonAndRegisterWithAuditService, whose log directory /path/to/audit/files/ cannot be created.
  • With the writer constructed in the try block, shouldFailWithIOExceptionWhenCheckIntervalIsInvalidForNewFile fails with "RuntimeException: Error when initializing a secure CSV writer".
  • With the RotatableWriter of master, both testRejectsInvalidRotationRetentionCheckInterval… tests fail in their four rows: the file exists for unlimited and zero, and 500 microseconds and the overflowed interval throw an IllegalArgumentException. With a guard that rejects only zero milliseconds, the overflowed row fails in both with the IllegalArgumentException.
  • Three mutants of the guard each fail only their own test: without the retention clause, the retention-only test in its four rows; without the policy clause, testAcceptsAnyRotationRetentionCheckIntervalWithoutPolicy in its four rows; with <= 1, testAcceptsOneMillisecondRotationRetentionCheckInterval.
  • audit/core and audit/handler-csv pass locally, and the new tests passed 3 runs in a row.

Merging

Not in this PR

  • RotatableWriter schedules its checks in its constructor, and registerRotationHooks() sets the hooks after it, in a field that is not volatile. The first check runs one check interval later. Not observed; it follows from the memory model only.
  • A new secure topic whose check interval is invalid stores new keys for its empty file on each publish attempt, and a resumed one verifies its file, before the writer fails. master leaked a FileOutputStream on each attempt instead.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The change sits where the bug is, and its pin fails without it.

  • constructWriter now runs at SecureCsvWriter.java:183, after the keys are read and the resumed file is verified, so the checks the RotatableWriter schedules (RotatableWriter.java:131) cannot rename the file during the construction.
  • shouldNotRotateResumedFileWhileVerifyingIt fails in both rows with the SecureCsvWriter of master ("The CSV file was tampered: The file contains no CSV header", JDK 26, macOS) and passes at the head.
  • The open and close at SecureCsvWriter.java:106 keeps a file that cannot be opened an IOException for its topic.

question (non-blocking): Should a new topic whose RotatableWriter constructor fails leave its .keystore behind?

commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java:183, :163

For a new file the try block now writes InitialKey and CurrentKey into <file>.keystore (:163; KeyStoreSecureStorage.writeKey stores the keystore on each write) before constructWriter runs. With rotation enabled, maxFileSize 1000 and rotationRetentionCheckInterval "unlimited" (or "disabled"), the RotatableWriter constructor throws the same IOException as on master ("Rotation and retention check interval set to an invalid value", RotatableWriter.java:369-371), but master threw it before any key was written. The next start with a fixed config sees an empty file and overwrites both keys, so nothing is lost. If the leftover is acceptable, nothing changes; if a failed construction should leave nothing on disk, delete the keystore of a new file when constructWriter throws. Minor either way (traced, not run).


suggestion (non-blocking): No test pins what a failed construction of a new topic leaves on disk.

commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/SecureCsvWriterTest.java:355, SecureCsvWriter.java:183

shouldNotRotateResumedFileWhileVerifyingIt pins the order on a resumed file, and shouldFailWithIOExceptionWhenFileCannotBeOpened fails at the open (SecureCsvWriter.java:106), before any key is written; nothing covers a new file whose writer fails after :163. The case below goes red if the key writes move back after the writer (the last assertion) or if the interval stops failing the construction. Set its last assertion by the answer to the question above. Not run.

@Test
public void shouldFailWithIOExceptionWhenCheckIntervalIsInvalidForNewFile() throws Exception {
    final Path logDirectory = Files.createTempDirectory("SecureCsvWriterTest");
    final File actual =
            new File(logDirectory.toFile(), CsvAuditEventHandler.SECURE_CSV_FILENAME_PREFIX + "access.csv");
    final CsvAuditEventHandlerConfiguration config = createBasicSecureConfig("5 minutes");
    config.getFileRotation().setRotationEnabled(true);
    config.getFileRotation().setMaxFileSize(1000);
    config.setRotationRetentionCheckInterval("unlimited");
    assertThatThrownBy(() -> new SecureCsvWriter(actual, new String[]{"FOO"}, CsvPreference.EXCEL_PREFERENCE,
            config, keyStoreHandler, random))
            .isInstanceOf(IOException.class)
            .hasMessageContaining("Rotation and retention check interval set to an invalid value");
    // the head leaves the keystore of the new file; master did not create it
    assertThat(CsvSecureUtils.keyStoreFile(actual)).exists();
}

suggestion (non-blocking): The resumed SecureCsvWriter in shouldNotRotateResumedFileWhileVerifyingIt is closed only when the test passes.

commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/SecureCsvWriterTest.java:322-333

The writer built at :322 is closed at :333, outside any try, while the first writer of the same test (:290) is in try-with-resources. If the assertion at :324, the wait or writeEvent fails, its RotatableWriter rotator (Executors.newScheduledThreadPool(1), non-daemon, RotatableWriter.java:373) goes on checking every 50 ms for the rest of the surefire JVM, and on Windows the file stays open. fileNamingPolicy moves before the writer, as the verification after the block reads it.

        final FileNamingPolicy fileNamingPolicy = new TimeStampFileNamingPolicy(actual, rotationFileSuffix, "");
        try (SecureCsvWriter secureCsvWriter = new SecureCsvWriter(
                actual, new String[]{header}, slowToVerify, config, keyStoreHandler, random)) {
            assertThat(held.get()).as("verification held").isTrue();

            // the first check after the construction rotates the file
            final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
            while (fileNamingPolicy.listFiles().isEmpty() && System.nanoTime() < deadline) {
                Thread.sleep(10);
            }
            secureCsvWriter.writeEvent(singletonMap(header, "after"));
        }

suggestion (non-blocking): On Windows, only the rotationInterval row is sure to catch a reverted order.

commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/SecureCsvWriterTest.java:273-281, :301

With the SecureCsvWriter of master on Windows, a check during the 500 ms hold cannot rename the file the verifier holds open (FileInputStream does not share delete), and RotatableWriter.rotate still resets lastRotationTime. The rotationInterval row then rotates no more within the hour and fails hasSize(1). The maxFileSize row stays over its size and rotates at the next check after verify() closed its reader, usually once the constructor has returned, so it fails only when a check renames the file before the verifier opens it. Traced, not run on Windows. A line in the data provider's comment saying which row pins the order on Windows keeps that from being lost.

@vharseko
vharseko force-pushed the issue-343-secure-csv-rotate-during-verify branch from 9cb942e to 7ede4f2 Compare October 18, 2026 09:51
@vharseko

Copy link
Copy Markdown
Member Author

Round 1 is in 7ede4f2, on a branch rebased onto the current master.

Question: the .keystore that a new topic leaves when its writer fails. Left as is. master did not leave the disk clean either: the RotatableWriter constructor opened the file (RotatableWriter.java:128) before it threw for the interval (:370), so the empty .csv stays on both; the head adds its keystore. The next start with a fixed interval reads the empty file as a new one and overwrites both keys: run against both, the file written at that start passes verification. A cleanup there would add a file delete to a configuration error path, with no effect on the next start.

Suggestion: a test for a new file whose writer fails. Added as shouldFailWithIOExceptionWhenCheckIntervalIsInvalidForNewFile, without the last assertion: the keystore it leaves is not a contract, and a reverted order is pinned by shouldNotRotateResumedFileWhileVerifyingIt. What the test pins is the IOException: with the writer constructed in the try block, it fails with "RuntimeException: Error when initializing a secure CSV writer", which CsvAuditEventHandler.startup() does not catch for the topic.

Suggestion: the resumed writer is closed only when the test passes. Done as proposed.

Suggestion: on Windows, only the rotationInterval row is sure to catch a reverted order. The data provider says so now, and that the maxFileSize row fails there when a check renames the file before the verifier opens it.

Found while tracing the question. The RotatableWriter constructor opened its file before it rejected the interval, and the caller of a constructor that throws gets no writer to close, so the case of the new test left the file open. An interval under one millisecond passed that check, and failed after the open with the IllegalArgumentException of scheduleAtFixedRate. The constructor now checks the interval before it opens the file, and rejects an interval under one millisecond with the same IOException: RotatableWriterTest.testRejectsInvalidRotationRetentionCheckIntervalBeforeOpeningTheFile, which fails in its three rows with the RotatableWriter of master. The description has a section on it.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The writer now starts its checks only on a file it has verified, and an invalid interval fails before anything is opened.

  • SecureCsvWriter constructs its RotatableWriter last (SecureCsvWriter.java:183), after the keys are read and the resumed file verified; on windows-latest JDK 11 and 27 both rows of shouldNotRotateResumedFileWhileVerifyingIt rotate once and pass.
  • RotatableWriter.getRotationCheckInterval (RotatableWriter.java:365-377) rejects the interval before constructWriter opens the file (:132), so a throwing constructor leaves no stream open, and "500 microseconds" becomes an IOException the handler reports for the topic.
  • The append probe at SecureCsvWriter.java:106 keeps lastModified() on Windows too: the rotationInterval row, due only from an mtime set two hours back, rotates on both Windows rows.

issue (non-blocking): The interval guard lets an overflowed, negative check interval through to the IllegalArgumentException after the open.

commons/audit/core/src/main/java/org/forgerock/audit/events/handlers/writers/RotatableWriter.java:371-372, :132, :383

Duration.merge adds the parts of a composite without an overflow check (commons/util/util/src/main/java/org/forgerock/util/time/Duration.java:223, :227): "9223372036854775807 ms, 1 ms" parses to an interval that is neither unlimited nor zero, with to(MILLISECONDS) = Long.MIN_VALUE (probed against this head's Duration). The guard's == 0 lets it pass, constructWriter opens the file, and scheduleAtFixedRate throws IllegalArgumentException for a period <= 0, which CsvAuditEventHandler.startup() does not catch for the topic. master does the same, so this is no regression and the input is contrived; but the commit says the constructor rejects any interval under one millisecond.

        // RotatableWriter.java:371-372
        if ((!rotationPolicies.isEmpty() || !retentionPolicies.isEmpty())
                && (rotationCheckInterval.isUnlimited() || rotationCheckInterval.to(TimeUnit.MILLISECONDS) <= 0)) {

                // RotatableWriterTest.invalidRotationRetentionCheckIntervals
                // Duration adds its parts without an overflow check: this one is negative
                {"9223372036854775807 ms, 1 ms"}

Pin: with == 0 the new row gets the IllegalArgumentException and an existing file instead of the IOException (by reading, not run); with <= 0 it passes.


suggestion (non-blocking): A new secure topic with an invalid check interval rewrites its .keystore on every publish attempt.

commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java:163, :183, commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvAuditEventHandler.java:299-308, :463-469

Your round-1 answer covers the keystore left after startup. With no writer for the topic, each publish runs getWriter → resetAndReopenWriter → a new SecureCsvWriter, which stores fresh random keys (:163, the keystore written twice) before constructWriter fails (:183), and the event fails with a BadRequestException. master leaked one FileOutputStream per attempt instead, so this is not worse in kind and leaving it is reasonable; checking the interval before the keys are stored would remove the churn.


suggestion (non-blocking): The check-interval guard is pinned only with a rotation policy.

commons/audit/core/src/test/java/org/forgerock/audit/events/handlers/writers/RotatableWriterTest.java:244, commons/audit/core/src/main/java/org/forgerock/audit/events/handlers/writers/RotatableWriter.java:371-372

All three rows of testRejectsInvalidRotationRetentionCheckIntervalBeforeOpeningTheFile set setRotationEnabled(true) and setMaxFileSize, so three mutants of the guard line survive: dropping || !retentionPolicies.isEmpty() (a retention-only config with "zero" opens the file and gets the IllegalArgumentException again), dropping the policy clause (a config with no policy and "zero" is rejected), and == 0 → <= 1 ("1 millisecond" is rejected). By reading; the mutants were not run.

    @Test(dataProvider = "invalidRotationRetentionCheckIntervals")
    public void testRejectsInvalidCheckIntervalWithRetentionOnlyBeforeOpeningTheFile(
            String rotationRetentionCheckInterval) throws IOException {
        final File file = getTempFile();
        Files.delete(file.toPath());
        final FileBasedEventHandlerConfiguration configuration = new DefaultFileBasedAuditEventHandlerConfiguration();
        configuration.setRotationRetentionCheckInterval(rotationRetentionCheckInterval);
        configuration.getFileRetention().setMaxNumberOfHistoryFiles(1);

        assertThatThrownBy(() -> new RotatableWriter(file, configuration, true))
                .isInstanceOf(IOException.class)
                .hasMessageContaining("Rotation and retention check interval set to an invalid value");
        assertThat(file).doesNotExist();
    }

    @Test(dataProvider = "invalidRotationRetentionCheckIntervals")
    public void testAcceptsAnyCheckIntervalWithoutPolicy(String rotationRetentionCheckInterval) throws IOException {
        final FileBasedEventHandlerConfiguration configuration = new DefaultFileBasedAuditEventHandlerConfiguration();
        configuration.setRotationRetentionCheckInterval(rotationRetentionCheckInterval);

        new RotatableWriter(getTempFile(), configuration, true).close();
    }

    @Test
    public void testAcceptsOneMillisecondCheckInterval() throws IOException {
        final FileBasedEventHandlerConfiguration configuration = new DefaultFileBasedAuditEventHandlerConfiguration();
        configuration.setRotationRetentionCheckInterval("1 millisecond");
        configuration.getFileRotation().setRotationEnabled(true);
        configuration.getFileRotation().setMaxFileSize(MAX_BYTES_TO_WRITE);

        new RotatableWriter(getTempFile(), configuration, true).close();
    }

Pin: the first test kills the dropped retention clause, the second the dropped policy clause, the third <= 1.

@vharseko

Copy link
Copy Markdown
Member Author

Round 2 is in 40c56b2, on the current master.

Issue: an overflowed, negative check interval passes the guard. Fixed: the guard rejects an interval of <= 0 milliseconds, and invalidRotationRetentionCheckIntervals has the row "9223372036854775807 ms, 1 ms". With == 0, the row fails in both tests that take the provider, with the IllegalArgumentException of scheduleAtFixedRate; with <= 0 it passes.

Suggestion: a new secure topic with an invalid check interval rewrites its .keystore on every publish attempt. Left as is. Each attempt finds the file the probe left empty, so the keys it stores are those of an empty file, and the first start with a valid interval stores its own. A resumed topic verifies its file on each attempt before the writer fails, for the same reason. Checking the interval before the keys would need the guard of RotatableWriter made public in audit/core, or copied into SecureCsvWriter, for a configuration that fails every event of the topic either way; master leaked a FileOutputStream on each attempt instead.

Suggestion: the check-interval guard is pinned only with a rotation policy. Added as proposed, with each writer assigned to the rotatableWriter field that tearDown() closes, as the other tests of the class do:

  • testRejectsInvalidRotationRetentionCheckIntervalWithRetentionOnlyBeforeOpeningTheFile: without || !retentionPolicies.isEmpty(), it fails in its four rows.
  • testAcceptsAnyRotationRetentionCheckIntervalWithoutPolicy: without the policy clause, it fails in its four rows.
  • testAcceptsOneMillisecondRotationRetentionCheckInterval: with <= 1, it fails.

Each mutant was run, and only its test failed. With the RotatableWriter of master, both tests that reject fail in their four rows, and both tests that accept pass.

@vharseko
vharseko requested a review from maximthomas October 19, 2026 07:33
vharseko added a commit that referenced this pull request Oct 19, 2026
…s to resume it (#341)

Fixes #340.

## Failed construction

The `SecureCsvWriter` constructor opened the audit file, and with
rotation a `RotatableWriter` with its rotator thread, before it read the
keys and verified a file it resumes, and closed neither when that
failed. It now closes the writer in its `catch` block; a
`RuntimeException` from that close is added to the original one as
suppressed (`TextWriterAdapter.close()` logs an `IOException` of the
close instead of throwing it). `new HmacCalculator(...)` moves into the
`try` block, since it can fail after the file is opened too.

## Exception type

An `IOException` raised during the construction is now thrown as is
instead of being wrapped in `RuntimeException: Error when initializing a
secure CSV writer`. A topic whose file cannot be resumed, or whose
keystore cannot be loaded, now always fails this way:

- its file fails verification;
- its file cannot be parsed or decoded, as a crash while a row is
written leaves it: a row with fewer columns than the header or an
unclosed quote (`SuperCsvException`), a final signature cut short so
that it no longer decodes as Base64 (`NullPointerException`), a header
cut short to one column (`ArrayIndexOutOfBoundsException`).
`CsvSecureVerifier.verify()` lets these through; the constructor turns
them into an `IOException` that keeps the cause, as
`CsvSecureArchiveVerifier` turns any exception of the verification into
a failed result;
- its headers do not match;
- its keystore is missing or holds no initial key. `JcaKeyStoreHandler`
reads a missing keystore file as an empty keystore, and
`CsvSecureVerifier` failed on it with an `IllegalStateException`. The
constructor now checks the initial key before it verifies the file, and
fails with an `IOException` that names the keystore;
- its keystore cannot be opened or loaded, whether the file is resumed
or not: a directory or a file that cannot be read in its place
(`FileNotFoundException`), an empty keystore, as a crash while it was
stored leaves it, or a keystore stored under another password
(`KeyStoreBuilder` throws an `IllegalStateException` for both), or a
corrupted keystore, on which `KeyStore.load` can throw a
`RuntimeException`, such as a `ClassCastException` or a
`NullPointerException`, before it checks the digest of the keystore. The
constructor turns it into an `IOException` that names the keystore and
keeps the cause. This is not only a file to resume: the keystore is
stored when the writer is constructed, the header only with the first
event, so a topic that got no event keeps an empty file next to its
keystore;
- its keystore holds no current key. The verifier already rejects such a
file ("Final HMAC key is null"); the check after it now throws an
`IOException` too, instead of an `IllegalStateException`.

`CsvAuditEventHandler` catches only an `IOException` there:

- `startup()` logs the failure for the topic and opens the other topics.
Before, the exception left the loop over the topics and
`AuditServiceImpl.startup()`, which catches only a `ResourceException`,
and the audit service stayed in the `STARTING` state.
- A writer reopened at runtime fails with a `BadRequestException`
through `resetAndReopenWriter()`, and `publishEvent()` returns it as a
failed promise. #347 turns it into an `InternalServerErrorException`
(#364).

Behaviour change: the password of a topic's keystore is derived from the
`Password` key of the handler keystore. After that key changes, every
topic that has a keystore, a topic that got no event yet included, now
fails on its own with an `IOException`, which `startup()` logs, instead
of failing the whole startup; a topic without a keystore still starts
with a new one.

Behaviour change: a topic whose file fails verification at startup no
longer keeps the audit service in the `STARTING` state, where every
request got a 503. Its publishes fail, but a read or a query of the
topic returns the rows of the file as they are: `getEntries()` reads the
file through `CsvSecureMapReader`, which drops the HMAC and signature
columns and verifies nothing. Reads have never verified a tamper-evident
file; a row changed while the service runs is returned the same way. The
file is verified by `CsvSecureArchiveVerifier` and when a writer resumes
it.

Other failures, such as a handler keystore without the `Password` or
`Signature` key, or a JVM that cannot create a JCEKS keystore
(`KeyStoreBuilder` throws an `IllegalStateException` whose cause is a
`KeyStoreException`, or a `NoSuchAlgorithmException` for the digest that
checks the keystore), are still wrapped in a `RuntimeException`, so a
misconfiguration still fails the startup.

## Tests

-
`SecureCsvWriterTest.shouldStopRotatorAndFailWithIOExceptionWhenFileCannotBeResumed`:
a written file is resumed with rotation and a size policy, from a thread
of its own `ThreadGroup`, where the rotator creates its thread. Before,
one of the following is done to it: an event row changed after it was
signed, its keystore deleted, the current key removed from its keystore,
its keystore truncated to 0 bytes, its keystore stored under another
password, its keystore replaced by a corrupted one, a row with fewer
columns than the header appended, a quoted value cut short appended, its
final signature cut short by 3 characters, its header cut short to one
column. The construction fails with an `IOException` that gives the
reason, and the rotator thread terminates.
-
`SecureCsvWriterTest.shouldStopRotatorAndFailWithIOExceptionWhenKeystoreOfEmptyFileCannotBeLoaded`:
the same for the empty file of a writer that got no event, whose
keystore is replaced by a directory, stored under another password, or
replaced by a corrupted one.
- The corrupted keystore is a JCEKS keystore whose secret key entry
holds a string in place of the sealed key: `KeyStore.load` throws a
`ClassCastException` on it, checked on JDK 11, 17, 21 and 27.
- Both tests have a 60 s `timeOut`, so a constructor that deadlocks
fails them instead of hanging the fork.
-
`CsvAuditEventHandlerTest.testStartupOpensOtherTopicsWhenTamperEvidentFileCannotBeResumed`:
a tamper-evident handler with two topics, whose file of the topic opened
first fails verification. The `AuditService` reaches the running state,
the other topic accepts an event, and a publish to the failing topic
returns a `BadRequestException`.
- `CsvWriterTest`: a header mismatch on resume is now an `IOException`.

Measured on JDK 27:

- With the `SecureCsvWriter` of `master`, every case of both writer
tests fails, and so do the handler test and
`CsvWriterTest.shouldResumeExistingCsvSecureFile`.
- Dropping the close fails every case of both writer tests
(`[pool-N-thread-1 alive]`).
- Catching only a `SuperCsvException` from the verifier fails the
signature-cut-short and header-cut-short cases.
- Rethrowing the `IllegalStateException` of an unloadable keystore when
the file is not resumed fails the empty-file case with the keystore
stored under another password.
- Catching only the `IOException` and the `IllegalStateException` of the
keystore fails the corrupted-keystore case of both writer tests.
- `audit/handler-csv` passes locally; `SecureCsvWriterTest` also on JDK
11.

Not covered by a test: the open file handle itself, which cannot be
observed portably on POSIX; the path without rotation; and a JVM that
cannot create a JCEKS keystore.

## Not in this PR

- While a topic's file cannot be resumed, every publish to it still
constructs a writer under the handler monitor and verifies the whole
file again; it no longer leaks.
- The `RotatableWriter` constructor leaks its file when
`scheduleRotationAndRetentionChecks()` rejects the check interval: #355.
- The rotator can rotate a resumed file while the constructor still
verifies it: #343, in #355.
- A resumed header with an empty column name fails with a
`NullPointerException`: #353.
…tatable writer once the resumed file is verified

SecureCsvWriter constructed its RotatableWriter, which schedules the rotation
and retention checks, before it read the keys and verified the file it
resumes, so a check due on that file could rotate it and its keystore during
the verification. The writer is now constructed last; the file is still
opened and closed first, so that a file that cannot be opened fails with an
IOException for its topic.
…nterval before opening the file

The RotatableWriter constructor opened its file before it rejected an
unlimited or zero rotation and retention check interval, and the caller of a
constructor that throws gets no writer to close: the file stayed open. An
interval under one millisecond passed that check, and failed after the open
with the IllegalArgumentException of scheduleAtFixedRate, which the CSV
handler does not catch for the topic. The constructor now checks the interval
before it opens the file, and rejects an interval under one millisecond with
the same IOException.

SecureCsvWriterTest: a new file whose writer fails reports an IOException,
the resumed writer is closed when the test fails, and the data provider says
which row catches a reverted order on Windows.
…nterval

Duration adds the parts of a composite without an overflow check, so
"9223372036854775807 ms, 1 ms" parses to a negative interval. It passed
the guard, which rejected zero milliseconds only, and failed after the
open with the IllegalArgumentException of scheduleAtFixedRate. The guard
now rejects an interval of zero milliseconds or less.

RotatableWriterTest pins the guard with a retention policy alone, with no
policy, and at one millisecond.
@vharseko
vharseko force-pushed the issue-343-secure-csv-rotate-during-verify branch from 40c56b2 to 8104030 Compare October 19, 2026 08:24
@vharseko

Copy link
Copy Markdown
Member Author

@maximthomas, rebased onto the current master (28afe37, with #341, #353 and #369): the head is 8104030.

The conflict was in the SecureCsvWriter constructor, with #341, and is resolved as the Merging section of the description says:

The comment on the open and close still holds with #341: a keystore that cannot be stored fails with a SecureStorageException, which is not an IOException, so the constructor still wraps it into a RuntimeException.

audit/handler-csv and RotatableWriterTest pass locally (82 and 31 tests).

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The rebase keeps the construction order and closes the round-2 gaps in the check-interval guard.

  • RotatableWriter.getRotationCheckInterval rejects an overflowed composite interval with <= 0 (RotatableWriter.java:373), and the "9223372036854775807 ms, 1 ms" row (RotatableWriterTest.java:240) runs through both rejection tests.
  • The retention-only rejection test (RotatableWriterTest.java:266), the no-policy acceptance test (:285) and the 1 ms test (:301) pin each clause of the guard; on ubuntu-latest JDK 27, audit.core runs 13 more tests than master, all green.
  • The conflict resolution drops #341's catch-block csvWriter.close() (SecureCsvWriter.java:213), since the writer is now constructed after the try (:223); the #341 tests and the PR's four handler-csv invocations pass on all nine build rows.

vharseko added a commit that referenced this pull request Oct 19, 2026
…rotation (#342)

Fixes #339. Fixes #372. Fixes #373.

## Resuming a file with no event since the last rotation

After a rotation, the live file holds the header and the signature
chained from the archive. `CsvSecureVerifier` kept that signature for
the chain but did not count it as a final signature, so the file failed
with `Missing final signature`, and `SecureCsvWriter` could not resume
it after a restart. The signature chained into row 2 now counts as the
final one until an event follows. A file truncated to these two rows
after later events is still rejected, as the current key in its keystore
no longer matches.

## Signatures around a rotation

`SecureCsvWriter` now tracks whether events were written since the last
signature, and signs only then:

- A rotation signs them whether or not the scheduled signature could be
cancelled. When the event that rotated the file was the first one since
a periodic signature, no signature was scheduled yet, and the archive
ended with that event unsigned: `Final HMAC key doesn't match expected
value` (#372).
- No signature is written or scheduled with no event to cover. After a
rotation on write, `writeEvent()` scheduled a signature that `close()`
wrote into the new file over the last HMAC of the previous file, which
the new file does not hold, so it failed at row 3. This is the failure
that `shouldRotateCsvAndKeyStoreFile` asserted for its last archive
(CAUD-225), and such a live file could not be resumed either.
- A rotation before any signature, by the `rotate` action or by a
time-based rotation of a topic with no event since startup, leaves the
new file empty instead of writing an empty chained signature (`,,`), on
which the verifier threw a `NullPointerException` when the writer
resumed the file.
- The header goes in the same write as the first event. Written on its
own, it could rotate the file when a time-based rotation was due, and
that rotation had no event to sign: the archive held only the header and
failed with `Missing final signature`, and the event went into the new
file without a header. This is likely after a restart over the empty
live file that the rotation above leaves: its modification time counts
as the last rotation, and the first event can come before the first
rotation check.

A second `close()` does nothing. As `close()` now always runs the
signature step, which flushes the writer, a second call with rotation
threw `IOException: Stream closed`.

## An event written while the periodic signature completes

`writeEvent()` scheduled a signature only when the scheduled one was
done. The periodic task releases `signatureLock` and flushes the writer
before its `FutureTask` is done, so an event written in this window
scheduled none, and stayed unsigned until the next event, a rotation or
`close()` (#373). A process killed meanwhile left a live file that
failed verification at the next start.

The writer now keeps the scheduled task until it starts signing, in
`pendingSignature`. `writeEvent()` schedules a signature for unsigned
events when no task is pending, and a task clears `pendingSignature`
once it holds the lock, before it signs: an event written after that
comes after its signature, and schedules the next one, whether the task
is done or not. A rotation or `close()` that cancels the scheduled task
clears it too.

A task clears `pendingSignature` only if it is that task.
`cancel(false)` returns `true` for a task that has started and waits for
the lock, as a `FutureTask` stays `NEW` until its `run()` returns, and
the task still runs. With a plain flag, this gives the interleaving that
#373 notes: an event schedules a second task, the first one clears the
flag when it gets the lock, and the next event schedules a third;
`close()` cancels only the last one, and waits for the second up to the
signature interval. The signature task is now one `SignatureTask` per
schedule instead of one shared `Runnable`, so that a task can tell
whether it is the pending one.

## Verifier

`CsvSecureVerifier` also:

- accepts an empty file whose keys match, as the rotation above archives
one;

This replaces the rule of #332, which rejected every file without a
header: a file truncated to nothing after events still fails, as the
current key of its keystore moved on.
`CsvSecureVerifierTest.shouldNotVerifyEmptyFile` now asserts that
failure reason.

- rejects a row 2 signature that is not base64;
- rejects a signature row that holds other values: the data and HMAC
columns of signature rows were not checked, so an event could be written
into them;
- reports a row with neither an HMAC nor a signature as an incorrect
HMAC instead of throwing.

## Tests

- `SecureCsvWriterTest`: resuming after a forced rotation and after a
rotation on write, an event that rotates the file after a periodic
signature, a rotation before the first event, a file truncated to the
chained signature, a malformed or empty chained signature, signature
rows that hold other values, a first event whose write rotates the file,
a second `close()`. `shouldRotateCsvAndKeyStoreFile` now expects every
archive and the live file to pass, with the plain and the `.gz` suffix;
it has 5 archives instead of 6, as the 6th came from the redundant
signature of `close()`.
- `SecureCsvWriterTest.shouldGenerateHMACColumn` has a 60 s time-out. It
fails when `unsignedEvents` is cleared after the signature row is
written instead of before: that row then schedules another signature,
and `close()` waits for it for the whole signature interval.
- `SecureCsvWriterTest`, for #373:
- `shouldSignAnEventWrittenWhileThePeriodicSignatureCompletes`: the
writer's `flush()`, which `writeSignature()` calls once the signature
row is written, writes a second event the first time it runs. A
signature must follow that event, and the live file must pass
verification while the writer is still open. With the `isDone()`
condition it fails: no second signature within 10 s.
-
`shouldSignPeriodicallyAnEventWrittenAfterARotationCancelledTheScheduledSignature`:
a forced rotation cancels the scheduled signature, and the next event
must get a periodic one. It fails when the cancel leaves
`pendingSignature` set.
-
`shouldScheduleNoOtherSignatureWhenARotationSignsWhileTheScheduledOneWaitsForTheLock`:
the test holds the lock until the scheduled signature waits for it,
rotates and writes events, and `close()` must return in less than half
the 3 s interval. When a task clears `pendingSignature` whatever it
holds, `close()` takes 2.7 s.
- `CsvWriterTest.shouldCreateNewAuditFileAfterRotation` verifies the
live file, in place of a commented-out TODO.
- The temporary directories of the `SecureCsvWriterTest` tests this PR
adds or changes are under `target`.

Each fix was disabled in turn, and its test failed. `audit/core` and
`audit/handler-csv` pass locally, and `SecureCsvWriterTest` passed 5
runs in a row.

## Known limits

- The signature chained into row 2 is not authenticated: a live file
with no event since the rotation passes with any base64 value there. No
tool checks the link between files (`CsvSecureArchiveVerifier` verifies
each file on its own), the row can hold no event, and a truncation is
caught by the keys.
- Files written by earlier versions with a signature over the HMAC of
the previous file at row 3, or with an empty chained signature, still
fail verification: the first needs the HMAC of the previous archive, the
second holds nothing to verify. To resume such a live file, move it and
its `.keystore` aside before the restart.

Merging with #347 or #348 does not conflict, and #362 does not touch
these files. #355 conflicts with `master` in the constructor of
`SecureCsvWriter`, and with this PR in no other place.
@vharseko
vharseko merged commit d9237ec into OpenIdentityPlatform:master Oct 19, 2026
14 checks passed
@vharseko
vharseko deleted the issue-343-secure-csv-rotate-during-verify branch October 19, 2026 16:43
vharseko added a commit that referenced this pull request Oct 19, 2026
#347)

Fixes #334.
Fixes #350.
Fixes #349.
Fixes #365.
Fixes #363.
Fixes #364.
Fixes #351.

## The race

`CsvAuditEventHandler` looked up the writer of a topic without a lock;
only the reset after a failed write and the rotations took the handler
monitor. A publisher that had the writer when the `rotate` action closed
it wrote into the closed writer. With rotation and buffering disabled,
`TextWriter.Stream` wrote through a `PrintWriter`, which swallows the
`IOException`, so the retry never ran: the event was lost and
`publishEvent()` reported success. A stale `SecureCsvWriter` also stored
its keystore over the one of the new file.

With rotation enabled
([comment](#334 (comment))),
the `rotate` action looked up the writer without the monitor too, and
could call `forceRotation()` on a writer that a concurrent reset had
closed.

## The fix

The issue offers two ways out: make a write to a closed writer fail, so
that the retry opens the current writer, or make `rotate` wait for the
publishers that are already writing. This PR does the latter. A closed
writer that throws would not cover the path with rotation enabled, and
would leave the gap between the final signature and the close of the
stream in `SecureCsvWriter.close()` that #337 left to this issue. A
write to a closed writer now fails as well (#349, below), but only the
lock covers both paths.

A `ReentrantReadWriteLock` per topic guards the writer of the topic, and
replaces the handler monitor:

- a publisher looks up the writer and writes the event under the read
lock. If the topic has no writer, or the write fails, it retries under
the write lock, with a new writer unless another thread has replaced the
writer meanwhile;
- the `rotate` action with rotation enabled holds the read lock during
`forceRotation()`: publishers keep writing, and a reset cannot close the
writer meanwhile;
- the default rotation, `startup()` and `shutdown()` take the write
lock, so they wait for the events being written. The rotated file is
still compressed after the lock is released (#332).

With a lock per topic, the other topics keep publishing while the writer
of one topic is replaced. With a single lock they would not: new readers
of a `ReentrantReadWriteLock` also queue behind a waiting writer, so one
event being written plus a waiting rotation would stop every topic. The
retry now also opens a writer when a failed reset of another thread has
left the topic without one, where it threw a `NullPointerException`
before.

## Events after `shutdown()` (#350)

`shutdown()` closed the writers but left them in the map, and nothing
marked the handler as shut down. An event published afterwards went to
the closed writer of its topic. With rotation disabled, it was lost
while `publishEvent()` reported success, and a tamper-evident writer
still stored its next key, so the file failed verification and could not
be resumed. With rotation enabled, the closed `RotatableWriter` threw,
and the retry opened a new writer that nothing closed. The `rotate`
action after `shutdown()` also rotated the file and opened a new writer,
in both modes.

`shutdown()` now marks the handler as shut down before it takes the
first write lock, and removes each writer it closes from the map. A
topic without a writer then gets no new one:

- the retry under the write lock fails with a
`ServiceUnavailableException`, so the event is reported as lost, and is
written neither to a closed writer nor to a new one. A topic that has a
writer still resets it after a failed write, as `startup()` may have
reopened it already;
- the `rotate` action finds no writer, and fails with a
`ServiceUnavailableException`, as a publish does (#364, below).

An event that has the read lock of its topic when `shutdown()` gets
there is still written, and `shutdown()` waits for it. `startup()`
clears the mark once it has opened the writers, also when a writer
throws a `RuntimeException` (#340), so that a restarted handler opens a
writer again, at the next event, for a topic whose file it could not
open. Until `startup()` returns, an event or a `rotate` action on a
topic that it has not opened yet fails with a
`ServiceUnavailableException`, as the Javadoc of `startup()` now states.
Before, such an event went to the closed writer of its topic, as above,
and with rotation enabled `startup()` then replaced the writer that the
retry had opened, without closing it. `startup()` now closes a writer
that an event opened before it, or after a `startup()` that threw,
before it opens a new one.

The retry under the write lock also rethrows a `ResourceException` as
is. `ResourceException` is an `IOException`, so the
`ServiceUnavailableException` would have become an
`InternalServerErrorException`, and the `InternalServerErrorException`
of a failed reset would have been wrapped into another one, since the
reset moved into that `try`.

## Write failures (#349)

With rotation disabled, `StandardCsvWriter` and `SecureCsvWriter` write
through `TextWriter.Stream`, which wrote through a `PrintWriter`. A
`PrintWriter` never throws: it sets an error flag, which nothing read. A
write that failed, on a full disk, on an I/O error or on a closed
stream, was lost while `publishEvent()` reported success, the retry
never ran, and a tamper-evident writer still stored the next key after
the lost row, so the file failed verification and could not be resumed.

`TextWriter.Stream` now writes through a `BufferedWriter` over an
`OutputStreamWriter`, the two writers that the `PrintWriter` wrapped, so
the buffering and the charset are the same, and `write()` and `flush()`
throw the `IOException` of the stream with its message:

- the retry of the handler opens a new writer for the event, as it does
with rotation enabled, where `RotatableWriter` already threw;
- a tamper-evident writer stores the next key only after the row is
flushed, so it no longer stores the key of a row it could not write;
- with buffering enabled, `AsynchronousTextWriter` logs the failure,
which was silent before;
- a failure to close the stream is logged, as `RotatableWriter` does.

After a failure, the buffers of these writers are unusable: the encoder
of the `OutputStreamWriter` is left with its buffer flipped, so the next
flush writes nothing and returns, and a close writes a part of a failed
text longer than the buffers. Several publishers write to the same
`StandardCsvWriter` under the read lock, so the flush of the next one
would report success for an event that the failure dropped.
`TextWriter.Stream` therefore fails every write and flush after the
first failure, with that failure as the cause, and `shutdown()` then
closes the stream without writing what the buffers hold. A write or a
flush checks for a failure, and records its own, under the lock of the
writer: a flush that waits for a flush that fails would otherwise find
the buffers empty, and return. Every publisher whose event was in the
buffers gets the `IOException`, and the handler retries the event with a
new writer. A close that fails records its failure as well, and closes
the stream, which the close of the buffers leaves open on JDK 8 and 11,
and on older updates of 17 and 21. A caller that logs or throws only the
next failure, such as the flush in the `finally` block of
`SecureCsvWriter.writeSignature()`, still gives the first one, such as a
full disk.

`StandardCsvWriter` writes the header of a new file in its constructor,
which can now throw; it then closes the file, and the rotator thread
with rotation, as the caller gets no writer to close.

`write()` and `flush()` of `TextWriter.Stream` now declare
`IOException`. In this repository only the two CSV writers use the
class, through the `TextWriter` interface, which declared it already;
code that calls them on a `TextWriter.Stream` reference has to handle
it. `write(null)` throws a `NullPointerException`, where
`PrintWriter.print(null)` wrote `null`.

## `RotatableWriter` after a failure (#365)

`RotatableWriter`, which writes when rotation is enabled, had the same
buffers and the same flaw: with two publishers, the flush of the second
one returned after the flush of the first one failed; `close()`, which
the reset calls, wrote 8192 bytes of a failed event into the file; and a
rotation went on with a new writer, on which the flush of an event that
the failure dropped succeeded.

`TextWriter.Stream` and `RotatableWriter` now share `FailFastWriter`, a
package-private `Writer` that holds the logic above: it fails every
write and flush after the first write, flush or close that fails, with
that failure as the cause, and closes the stream without writing what
the buffers hold. `RotatableWriter` does not rotate a file after a
failure: the rotation checks log the failure, and `forceRotation()`
throws it, until the writer is replaced. It keeps the writer of the
failure until then, so its later flushes fail too.

`RotatableWriter` gets a package-private constructor that opens the file
through a `FileStreamOpener`, so that the tests can open a stream that
fails.

`RotationContext.getWriter()` now returns the `FailFastWriter`, which is
not a `BufferedWriter`. Its declared type is `Writer`, which the hooks
of this repository use; a `RotationHooks` that casts it to
`BufferedWriter` gets a `ClassCastException`.

## Buffered writes (#363)

With buffering enabled, `AsynchronousTextWriter` writes the records on
its own thread, and only logged a failure of the wrapped writer: the
handler never learned of it, never replaced the writer, and each record
logged a stack trace while the failure lasted.

`AsynchronousTextWriter` now keeps the first failure of the wrapped
writer, and fails the next `write()` and `flush()` with it as the cause,
so that the retry of the handler opens a new writer. It logs the first
failure with its stack trace, the next ones at debug level, and, at
shutdown, the number of records that could not be written. `flush()` now
throws the failure of the wrapped writer instead of logging it, and
declares `IOException`: code that calls it on an
`AsynchronousTextWriter` reference has to handle it. The first failure
is kept for the life of the writer, as the class Javadoc now states: the
caller replaces the writer after a failure, as the handler does. The
writers of this package that it wraps fail their later writes after a
failure too, so a writer that went on would not write again either.

`shutdown()` of a buffered handler now fails with an
`InternalServerErrorException` when the wrapped writer has failed
already, or fails to flush the records handed to it, as the handler does
without buffering when the flush fails. The records still queued are
written while the writer closes, after that flush: a failure there is
only logged, with their number. Every writer is still closed.

## Server errors (#364)

`CsvAuditEventHandler` reported an `IOException` of the audit file as a
`BadRequestException` (400): a failed write after the reset, a writer
that could not be opened, a file that could not be read or queried, and
a rotation that failed. These are failures of the server, which a client
may retry. They are now `InternalServerErrorException` (500), and
`queryEvents()` adapts its failure with
`ResourceExceptionsUtil.adapt()`, which keeps a `ResourceException` and
turns a `JsonValueException` into a `BadRequestException`. The `rotate`
action after `shutdown()` fails with a `ServiceUnavailableException`
(503), as a publish does. `BadRequestException` stays for an action
without a topic, an unknown action and an unknown topic. The `rotate`
action throws the exception of a rotation that does not happen after the
`try` of `forceRotation()`: a `ResourceException` is an `IOException`,
so its `catch` wrapped the exception into another one, whatever its
status.

## Failures after the event is written (#351)

The handler takes an `IOException` of a write for an event that is not
in the file: it closes the writer, opens a new one and writes the event
again. Two writers threw after the event was written:

- `SecureCsvWriter.writeEvent()` writes and flushes the row, then stores
the next HMAC key in the keystore of the file. When the key could not be
stored, the reset closed a writer whose file was intact, and the new
writer verified the file against the keystore, which still held the
previous key: it failed with "Final HMAC key doesn't match expected
value", and so did every later event of the topic, also once the
keystore could be written again. The writer now logs the failure and
goes on: the next row stores the key, and the event is reported as
published, as it is in the file.
- `RotatableWriter.write()` applies the rotation policies after it
writes the text, so a rotation that failed threw after the event was
written, and the handler wrote the event again to a new writer. With
tamper-evident logging, the rotation signs the file first, and a
keystore that cannot be stored failed it, so the reset broke the topic
as above. `write()` now logs the failure of the rotation, as the
scheduled checks do. A rotation that fails because the file cannot be
written, also when the file fails to close, leaves the writer failed
(#365): the flush that follows the write then fails, and the handler
writes the event again, which the failure dropped.

`CsvWriter.writeEvent()` now states that it does not throw once the row
is written, which the retry of the handler relies on.

## Tests

-
`CsvAuditEventHandlerTest.testActionToRotateFileKeepsEventBeingPublished`,
standard and tamper-evident: a publisher stops while it serializes a
field of its event, after it has the writer and before it writes to it,
and the `rotate` action runs meanwhile. Every event is then written
once, to the archive or to the new file, and with tamper-evident logging
both files pass verification with their own keystores. Without the fix,
the event being published is lost in both variants.
-
`CsvAuditEventHandlerTest.testActionToRotateFileLetsOtherTopicsPublish`:
an event of `activity` is written while the `rotate` action of `access`
waits for an event being written. With one lock for all topics, it times
out.
- `testActionToRotateFileCompressesOutsideHandlerMonitor` is renamed to
`testActionToRotateFileCompressesOutsideWriteLock`, as there is no
monitor any more.
- `CsvAuditEventHandlerTest.testPublishEventAfterShutdownFails`,
standard and tamper-evident, each with rotation disabled and enabled: an
event published after `shutdown()` fails with a
`ServiceUnavailableException` and is not in the file, and the
tamper-evident file passes verification. Without the fix, all four
variants report success.
- `CsvAuditEventHandlerTest.testActionToRotateFileAfterShutdownFails`,
with rotation enabled and disabled: the `rotate` action after
`shutdown()` fails and leaves the file as it is. Without the fix, it
rotates the file in both modes.
- `CsvAuditEventHandlerTest.testPublishEventAfterShutdownAndStartup`:
after `shutdown()` and `startup()`, a topic whose file could not be
opened at startup opens it at its next event.
- `TextWriterTest` (new, `audit/core`): a failure of the output stream
is thrown by `flush()` with its message; a write after `shutdown()`
throws; after a failed flush of two events, the next flush and write
throw too; the next failures have the first one as their cause; after a
failed write of a text longer than the buffers, `shutdown()` writes
nothing of it; a text written and flushed reaches the stream with its
byte count. Without the fix, all but the last fail: with the plain
`BufferedWriter`, the next flush returns, and `shutdown()` writes 8192
bytes of the failed text.
- `TextWriterTest.testStreamFailsTheFlushThatWaitsForAFailingFlush`: two
publishers flush at the same time, and the write of the first flush to
the stream fails while the second flush waits for it. Both flushes fail,
and nothing reaches the stream. Without the check under the lock, the
second flush returns.
- `TextWriterTest.testStreamFailsTheWriteThatWaitsForAFailingFlush`: a
publisher writes while the write of a flush to the stream fails. Both
fail, and nothing reaches the stream. Without the lock around the write,
the write returns, and puts its event into the buffers that the failure
left unusable.
- `TextWriterTest.testStreamClosesTheOutputStreamWhenTheShutdownFails`:
the close that writes the buffers fails, and the stream is closed.
Without the fix, the stream stays open on JDK 11. A probe of
`BufferedWriter.close()` over a stream that fails leaves the stream open
on 1.8.0_402 and 11.0.32 too, and closes it on 17.0.20, 21.0.12 and 27.
- `RotatableWriterTest.testFailsEveryWriteAndFlushAfterAFailure`,
`testDoesNotWriteTheTextOfAFailedWriteWhenClosed` and
`testDoesNotRotateAfterAFailure`, through a stream whose first write
fails. Without the fix, the second flush returns, `close()` writes 8192
bytes of the failed event, and `forceRotation()` rotates the file.
-
`RotatableWriterTest.testDoesNotRotateAfterTheRotationFailsToCloseTheFile`:
the close of a size rotation that `write()` triggers fails to write the
text, and the disk works again before the flush of the text.
`forceRotation()` and the flush fail, the file holds only the text
written before, and there is no archive. Without the fix,
`forceRotation()` rotates the file, and the flush succeeds on the new
file for the text that the failed close dropped.
- `AsynchronousTextWriterTest` (new, `audit/core`): after the wrapped
writer fails a write, a later `write()` fails with that failure as the
cause; `flush()` throws the failure of the wrapped writer, and the next
`write()` fails. Without the fix, no write fails, and `flush()` returns.
- `CsvAuditEventHandlerTest.testFailuresOfAuditFileAreServerErrors`:
with a directory in place of the audit file, a publish, a read, a query
and the `rotate` action fail with an `InternalServerErrorException`.
Without the fix, all four fail with a `BadRequestException`.
`testActionToRotateFileAfterShutdownFails` now expects a
`ServiceUnavailableException`, and
`testActionToRotateTamperEvidentFileKeepsFileWhenKeyStoreCannotBeRenamed`
an `InternalServerErrorException`.
-
`CsvAuditEventHandlerTest.testActionsThatTheRequestGetsWrongAreBadRequests`:
an action without a topic, an unknown action, and the `rotate` action of
an unknown topic fail with a `BadRequestException`.
-
`CsvAuditEventHandlerTest.testActionToRotateFileThatFailsIsServerError`:
with rotation enabled, the `rotate` action fails with an
`InternalServerErrorException` for a writer whose `forceRotation()`
throws an `IOException`, and for one whose `forceRotation()` returns
`false`, with the message of a rotation that does not happen. The
writers are put into the handler by reflection.
-
`CsvAuditEventHandlerTest.testPublishEventOpensWriterWhenTopicIsLeftWithoutOne`:
the writer of the topic removes itself from the handler, as a failed
reset of another publisher does, and fails the write. The event is
written once, to a new writer. Without the `newCsvWriter == null` check,
the publish fails with a `NullPointerException`.
-
`CsvAuditEventHandlerTest.testPublishEventDuringStartupResetsWriterOfReopenedTopic`:
after `shutdown()`, a writer is put into the handler by reflection, as
when `startup()` has reopened the topic and not returned yet, and fails
the write. The event is written once, to a new writer. Without the fix,
it fails with a `ServiceUnavailableException`.
- `CsvAuditEventHandlerTest.testStartupClosesTheWriterThatItReplaces`: a
writer put into the handler by reflection, as one that an event opened
after a `startup()` that threw, is closed by the next `startup()`.
Without the fix, `startup()` replaces it and leaves it open.
-
`CsvAuditEventHandlerTest.testRestartLetsOtherTopicsPublishWhenTamperEvidentKeyStoreCannotBeStored`:
after `shutdown()`, the file of the topic that `startup()` opens first
is deleted and its keystore made read-only, so the writer loads the
keystore but cannot store the first keys of the new file, and
`startup()` throws (#340). An event of the other topic is then
published. Without the fix, it fails with a
`ServiceUnavailableException`. The test fails if `startup()` does not
throw, so that it does not pass without the fix, and is skipped where
the keystore cannot be made read-only, as for root.
- `CsvWriterTest.shouldFailToWriteEventToClosedStandardCsvWriter` and
`shouldKeepSecureCsvFileVerifiableWhenWriteToClosedWriterFails`: an
event written to a closed writer fails with an `IOException`, and the
tamper-evident file still passes verification with its keystore. Without
the fix, both writes report success.
-
`CsvAuditEventHandlerTest.testPublishEventKeepsTamperEvidentWriterWhenKeyStoreCannotBeStored`,
with rotation enabled and disabled: a directory replaces the keystore
during one event, then the keystore is restored. Every event is
published and written once, and the file passes verification with its
keystore. Without the fix, that event fails with `RuntimeException:
Error when initializing a secure CSV writer` (#340), from the reset.
-
`CsvAuditEventHandlerTest.testPublishEventKeepsTamperEvidentWriterWhenRotationAfterItFails`:
with a size-based rotation, a directory replaces the keystore during the
event whose write rotates the file, so the signature of the rotation
fails. Every event is published and written once, to the archive that
the next event rotates, and the archive passes verification with its
keystore. Without the fix, the event fails the same way.
-
`RotatableWriterTest.testDoesNotFailTheWriteOfATextWhenTheRotationAfterItFails`:
a rotation hook fails at the write that follows a file of the maximum
size; the write and the flush return, and the text is in the file once.
Without the fix, the write throws the failure of the hook.

The default rotation under the read lock is caught by the first test,
and the compression under the write lock by the third. For #350, keeping
the closed writers in the map fails the last three tests, dropping the
check under the write lock fails the four variants of
`testPublishEventAfterShutdownFails`, and a `startup()` that does not
clear the mark fails the last test. For #349, storing the key before the
row is written fails
`shouldKeepSecureCsvFileVerifiableWhenWriteToClosedWriterFails` with
"Final HMAC key doesn't match expected value". For #351, throwing the
failure to store the key fails both variants of
`testPublishEventKeepsTamperEvidentWriterWhenKeyStoreCannotBeStored` and
`testPublishEventKeepsTamperEvidentWriterWhenRotationAfterItFails`, and
throwing the failure of the rotation from `write()` fails
`testPublishEventKeepsTamperEvidentWriterWhenRotationAfterItFails` and
`testDoesNotFailTheWriteOfATextWhenTheRotationAfterItFails`. A rotation
that logs the failed close of the file and goes on fails
`testDoesNotRotateAfterTheRotationFailsToCloseTheFile`. A
`BadRequestException` for the `IOException` of `forceRotation()`, or for
a rotation that does not happen, fails
`testActionToRotateFileThatFailsIsServerError`, and an
`InternalServerErrorException` for an unknown topic fails
`testActionsThatTheRequestGetsWrongAreBadRequests`. Throwing the
exception of a rotation that does not happen inside the `try` fails
`testActionToRotateFileThatFailsIsServerError` on its message. A retry
under the write lock that fails for any topic once the handler is shut
down fails `testPublishEventDuringStartupResetsWriterOfReopenedTopic`, a
`startup()` that does not close the writer it replaces fails
`testStartupClosesTheWriterThatItReplaces`, and a
`FailFastWriter.write()` outside the lock fails
`testStreamFailsTheWriteThatWaitsForAFailingFlush`. `audit/core`,
`audit/handler-csv`, `audit/handler-json` and `audit/handler-syslog`
pass locally on JDK 11 and 27, and the new tests of #334 passed 20 runs
in a row. One full run on JDK 11 failed
`CsvWriterTest.shouldCreateNewAuditFileAfterRotation`, on the archive of
an event that rotated the file after a periodic signature, which #342
fixes.

## Not covered by a test

- The `rotate` action with rotation enabled against a concurrent reset:
the reset needs a write that fails with an `IOException`, which a test
cannot cause with rotation enabled. The same lock covers that path, by
reasoning only.
- The retry of the handler after a write failure with rotation disabled
(#349): a test cannot make the file stream of a writer fail without a
full disk, so the writer tests close the stream instead, and
`TextWriterTest` uses a stream that fails.
- The close in the `StandardCsvWriter` constructor when the header
cannot be written, for the same reason.
- The retry of the handler after a failure with buffering enabled
(#363), for the same reason, and the log of `AsynchronousTextWriter`:
the tests of `audit/core` log through `slf4j-simple`, which a test
cannot read.
- The rotation checks after a failure (#365) run on a timer; they go
through the same `rotate()` as `forceRotation()`, which the test covers.

## Known limits

- A write that fails after part of its bytes reached the file leaves a
partial row, which the retry follows with the whole event.
- A tamper-evident writer computes the HMAC of a row, and moves to the
next key, before it writes the row. After a failed write, the reset
closes it: when a signature is due, the final signature covers the HMAC
of the row that is not in the file, and its write fails as well. The
file then ends with events that no signature covers, and the new writer
cannot resume it: the topic fails loudly, where it lost the row and
stored a key that broke the file silently before.
- With buffering enabled, the records queued before the failure shows
are reported as published, and are lost: their number is logged when the
writer is replaced. `shutdown()` does not fail for the records still
queued when it is called: their failure is only logged.
- Until the writer is replaced, the rotation checks log the failure at
each check interval.
- Until the keystore can be stored again, it holds the key of an earlier
row: the file fails verification meanwhile, and a handler stopped before
the next row cannot resume it. Each row logs the failure.
- A rotation that fails after it has renamed the file leaves the writer
half rotated. When the new file cannot be created or opened, the writer
keeps the closed one: the flush that follows the write fails, and the
handler writes the event again to a new file, while it is in the archive
already, as it did before. When a rotation hook fails after the rename,
the writer goes on with the new file, which may lack its header or, with
tamper-evident logging, the keys of its keystore; before, the write
threw, and the reset made the topic fail. The scheduled checks leave the
writer in the same states.

## Merging

- #348 makes `TextWriter.Stream` write in UTF-8. The two conflict in the
imports and the constructor of `TextWriter.Stream`; the merge is `new
FailFastWriter(stream, StandardCharsets.UTF_8)`.
- #341 and #353, merged meanwhile, add tests that expect a
`BadRequestException` for a topic whose file cannot be opened or resumed
(`testStartupOpensOtherTopicsWhenTamperEvidentFileCannotBeResumed`,
`testStartupWithAuditFileOfMoreColumnsThanTopicSchema`). With #364 the
handler answers an `InternalServerErrorException` there, and since the
rebase onto them they expect it. With #341, a file that cannot be
resumed fails with an `IOException`, and `startup()` opens the other
topics, so the restart test now fails the writer with a keystore that
cannot be stored, which is still a `RuntimeException`
(`testRestartLetsOtherTopicsPublishWhenTamperEvidentKeyStoreCannotBeStored`).
- #342 changes the condition that schedules a signature in
`SecureCsvWriter.writeEvent()`, two lines below the key store that this
PR stops throwing for. The two merge without a conflict, and so do this
PR and #355.

## Not in this PR

Found in the review of this change, older than it, and filed separately:

- #340, fixed by #341: a `RuntimeException` from `createCsvWriter()`
escapes `startup()`, `publishEvent()` and `handleAction()`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug concurrency Race conditions, locking and thread-safety java Pull requests that update java code tests Test code changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tamper-evident CSV audit: the rotator can rotate a resumed file while the writer still verifies it

2 participants