From eea4685ab5d7f454f00c3bdbfbef435753b6dc0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:18:55 +0900 Subject: [PATCH 01/10] test(artifact): fail closed on ledger persistence errors --- .../ArtifactLinkLedgerFailClosedTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerFailClosedTest.java diff --git a/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerFailClosedTest.java b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerFailClosedTest.java new file mode 100644 index 00000000..69950828 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerFailClosedTest.java @@ -0,0 +1,99 @@ +package com.clearfolio.viewer.artifact; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies that configured durable-ledger write failures do not publish only + * process-local authority or audit state. + */ +class ArtifactLinkLedgerFailClosedTest { + + @TempDir + Path temporaryDirectory; + + @Test + void failedIssuePersistenceDoesNotPublishInMemoryToken() throws Exception { + Path blockedParent = temporaryDirectory.resolve("issue-blocked"); + ArtifactLinkLedger ledger = new ArtifactLinkLedger(blockedParent.resolve("ledger.log")); + Files.writeString(blockedParent, "not a directory", StandardCharsets.UTF_8); + ArtifactLinkRecord record = record("token-issue", UUID.randomUUID()); + + assertThrows(IllegalStateException.class, () -> ledger.recordIssued(record)); + + assertTrue(ledger.findByTokenId(record.tokenId()).isEmpty()); + } + + @Test + void failedRevocationPersistenceDoesNotPublishInMemoryRevocation() throws Exception { + Path parent = temporaryDirectory.resolve("revoke-parent"); + Path ledgerPath = parent.resolve("ledger.log"); + ArtifactLinkLedger ledger = new ArtifactLinkLedger(ledgerPath); + ArtifactLinkRecord record = record("token-revoke", UUID.randomUUID()); + ledger.recordIssued(record); + blockFutureWrites(parent, ledgerPath); + + assertThrows( + IllegalStateException.class, + () -> ledger.revoke(record.tokenId(), Instant.EPOCH.plusSeconds(2), "operator", "test") + ); + + assertFalse(ledger.findByTokenId(record.tokenId()).orElseThrow().isRevoked()); + } + + @Test + void failedReadPersistenceDoesNotPublishInMemoryAuditEvent() throws Exception { + Path blockedParent = temporaryDirectory.resolve("read-blocked"); + ArtifactLinkLedger ledger = new ArtifactLinkLedger(blockedParent.resolve("ledger.log")); + Files.writeString(blockedParent, "not a directory", StandardCharsets.UTF_8); + UUID docId = UUID.randomUUID(); + ArtifactReadEvent event = new ArtifactReadEvent( + "tenant-a", + "subject-a", + docId, + "token-read", + null, + 200, + "trace-1", + Instant.EPOCH + ); + + assertThrows(IllegalStateException.class, () -> ledger.recordRead(event)); + + assertTrue(ledger.readEventsFor("tenant-a", docId).isEmpty()); + } + + private static ArtifactLinkRecord record(String tokenId, UUID docId) { + return new ArtifactLinkRecord( + tokenId, + "tenant-a", + "subject-a", + docId, + ArtifactLinkService.ARTIFACT_READ_SCOPE, + "viewer-preview", + "checksum", + null, + Instant.EPOCH, + Instant.EPOCH.plusSeconds(300), + null, + null, + null + ); + } + + private static void blockFutureWrites(Path parent, Path ledgerPath) throws Exception { + Files.delete(ledgerPath); + Files.delete(parent); + Files.writeString(parent, "not a directory", StandardCharsets.UTF_8); + } +} From 5b28d5a6c1f4915b9575bd5239eaafe894ce3d0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:30:50 +0900 Subject: [PATCH 02/10] fix(reliability): persist artifact ledger before publication --- .../viewer/artifact/ArtifactLinkLedger.java | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java index 897b95b2..9b2554db 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java @@ -66,8 +66,8 @@ public ArtifactLinkLedger(@Value("${clearfolio.artifact-link-ledger.path:}") Str * @param record issued artifact link record */ public synchronized void recordIssued(ArtifactLinkRecord record) { - issuedLinks.put(record.tokenId(), record); appendLine(serializeIssued(record)); + issuedLinks.put(record.tokenId(), record); } /** @@ -97,18 +97,14 @@ public synchronized Optional revoke( Instant revokedAt, String revokedBy, String reason) { - boolean[] changed = {false}; - ArtifactLinkRecord revoked = issuedLinks.computeIfPresent(tokenId, (ignored, current) -> { - if (current.isRevoked()) { - return current; - } - changed[0] = true; - return current.revoked(revokedAt, revokedBy, reason); - }); - if (changed[0]) { - appendLine(serializeRevoked(revoked)); + ArtifactLinkRecord current = issuedLinks.get(tokenId); + if (current == null || current.isRevoked()) { + return Optional.ofNullable(current); } - return Optional.ofNullable(revoked); + ArtifactLinkRecord revoked = current.revoked(revokedAt, revokedBy, reason); + appendLine(serializeRevoked(revoked)); + issuedLinks.put(tokenId, revoked); + return Optional.of(revoked); } /** @@ -117,8 +113,8 @@ public synchronized Optional revoke( * @param event artifact read event */ public synchronized void recordRead(ArtifactReadEvent event) { - readEvents.add(event); appendLine(serializeRead(event)); + readEvents.add(event); } /** From 2fc0eb61ad8b40b85c8828b22a6ffed5700d519e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 12:20:43 +0900 Subject: [PATCH 03/10] test(reliability): reject artifact token authority rebinding --- ...ifactLinkLedgerAuthorityRebindingTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java diff --git a/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java new file mode 100644 index 00000000..36a64b4b --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java @@ -0,0 +1,54 @@ +package com.clearfolio.viewer.artifact; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ArtifactLinkLedgerAuthorityRebindingTest { + + @TempDir + private Path tempDir; + + @Test + void duplicateTokenIdentifierCannotRebindIssuedAuthority() { + Path ledgerPath = tempDir.resolve("artifact-ledger.log"); + ArtifactLinkLedger ledger = new ArtifactLinkLedger(ledgerPath); + ArtifactLinkRecord original = issuedRecord("token-1", "tenant-a", UUID.randomUUID()); + ArtifactLinkRecord conflicting = issuedRecord("token-1", "tenant-b", UUID.randomUUID()); + + ledger.recordIssued(original); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> ledger.recordIssued(conflicting) + ); + + assertEquals("artifact token identifier is already issued", error.getMessage()); + assertEquals(original, ledger.findByTokenId("token-1").orElseThrow()); + assertEquals(original, new ArtifactLinkLedger(ledgerPath).findByTokenId("token-1").orElseThrow()); + } + + private static ArtifactLinkRecord issuedRecord(String tokenId, String tenantId, UUID docId) { + return new ArtifactLinkRecord( + tokenId, + tenantId, + "subject-a", + docId, + ArtifactLinkService.ARTIFACT_READ_SCOPE, + "viewer-preview", + "checksum", + null, + Instant.EPOCH, + Instant.EPOCH.plusSeconds(300), + null, + null, + null + ); + } +} From d8ddbe369a9ac7d87ebc25f4b17d48a5e6578410 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 12:24:05 +0900 Subject: [PATCH 04/10] fix(reliability): reject artifact token authority rebinding --- .../clearfolio/viewer/artifact/ArtifactLinkLedger.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java index 9b2554db..1c1b3676 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java @@ -63,9 +63,18 @@ public ArtifactLinkLedger(@Value("${clearfolio.artifact-link-ledger.path:}") Str /** * Records an issued artifact link. * + *

Token identifiers are immutable authorization identities. Reusing an + * already-issued identifier is rejected before durable append or process-local + * publication so a collision or caller error cannot rebind an existing token + * to another tenant, subject, or document.

+ * * @param record issued artifact link record + * @throws IllegalStateException when the token identifier was already issued or durable append fails */ public synchronized void recordIssued(ArtifactLinkRecord record) { + if (issuedLinks.containsKey(record.tokenId())) { + throw new IllegalStateException("artifact token identifier is already issued"); + } appendLine(serializeIssued(record)); issuedLinks.put(record.tokenId(), record); } From e1afe0f0935b6792d7b7274ceaf8a5140e783dca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 12:24:59 +0900 Subject: [PATCH 05/10] test(reliability): reject persisted token authority rebinding --- ...ifactLinkLedgerAuthorityRebindingTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java index 36a64b4b..15239ae8 100644 --- a/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java +++ b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java @@ -3,8 +3,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.time.Instant; +import java.util.Base64; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -34,6 +38,28 @@ void duplicateTokenIdentifierCannotRebindIssuedAuthority() { assertEquals(original, new ArtifactLinkLedger(ledgerPath).findByTokenId("token-1").orElseThrow()); } + @Test + void replayRejectsPersistedTokenAuthorityRebinding() throws Exception { + Path ledgerPath = tempDir.resolve("artifact-ledger-replay.log"); + ArtifactLinkRecord original = issuedRecord("token-1", "tenant-a", UUID.randomUUID()); + ArtifactLinkRecord conflicting = issuedRecord("token-1", "tenant-b", UUID.randomUUID()); + ArtifactLinkLedger ledger = new ArtifactLinkLedger(ledgerPath); + ledger.recordIssued(original); + Files.writeString( + ledgerPath, + issuedLine(conflicting) + System.lineSeparator(), + StandardCharsets.UTF_8, + StandardOpenOption.APPEND + ); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> new ArtifactLinkLedger(ledgerPath) + ); + + assertEquals("artifact link ledger contains an invalid line", error.getMessage()); + } + private static ArtifactLinkRecord issuedRecord(String tokenId, String tenantId, UUID docId) { return new ArtifactLinkRecord( tokenId, @@ -51,4 +77,29 @@ private static ArtifactLinkRecord issuedRecord(String tokenId, String tenantId, null ); } + + private static String issuedLine(ArtifactLinkRecord record) { + return String.join("\t", + "ISSUED", + encoded(record.tokenId()), + encoded(record.tenantId()), + encoded(record.subjectId()), + record.docId().toString(), + encoded(record.scope()), + encoded(record.purpose()), + encoded(record.artifactChecksum()), + "-", + record.issuedAt().toString(), + record.expiresAt().toString(), + "-", + "-", + "-" + ); + } + + private static String encoded(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } } From 823b994b552353fdd8a612232098ffaf7cb506ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:09:31 +0900 Subject: [PATCH 06/10] test(reliability): preserve ledger mismatch coverage without rebinding --- .../viewer/artifact/ArtifactLinkServiceTest.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkServiceTest.java b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkServiceTest.java index a469b106..afe0db20 100644 --- a/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkServiceTest.java @@ -595,18 +595,20 @@ private ArtifactLinkService serviceAt(ArtifactLinkLedger ledger, Instant instant } private void assertLedgerMismatch(UnaryOperator mutation) { - ArtifactLinkLedger ledger = new ArtifactLinkLedger(); - ArtifactLinkService ledgerService = serviceAt(ledger, NOW); + ArtifactLinkLedger issuingLedger = new ArtifactLinkLedger(); + ArtifactLinkService issuingService = serviceAt(issuingLedger, NOW); UUID docId = UUID.randomUUID(); ConversionJob job = succeededJob(docId); artifactStore.putPdf(docId, sampleBytes()); - ArtifactLinkResponse link = ledgerService.createLink(job, tenantContext(), null); - ArtifactLinkRecord record = ledger.findByTokenId(link.tokenId()).orElseThrow(); - ledger.recordIssued(mutation.apply(record)); + ArtifactLinkResponse link = issuingService.createLink(job, tenantContext(), null); + ArtifactLinkRecord record = issuingLedger.findByTokenId(link.tokenId()).orElseThrow(); + ArtifactLinkLedger verificationLedger = new ArtifactLinkLedger(); + verificationLedger.recordIssued(mutation.apply(record)); + ArtifactLinkService verificationService = serviceAt(verificationLedger, NOW); assertTokenStatus( HttpStatus.FORBIDDEN, - () -> ledgerService.verifyReadToken(docId, job, sampleBytes(), tokenFrom(link)) + () -> verificationService.verifyReadToken(docId, job, sampleBytes(), tokenFrom(link)) ); } From bc91ee409d6383a9a9986dc410280b6676db02d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:11:23 +0900 Subject: [PATCH 07/10] fix(reliability): fail closed on duplicate ledger replay --- .../viewer/artifact/ArtifactLinkLedger.java | 122 ++++-------------- 1 file changed, 27 insertions(+), 95 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java index 1c1b3676..55e6297f 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java @@ -26,28 +26,23 @@ */ @Repository public class ArtifactLinkLedger { - private static final String ISSUED = "ISSUED"; private static final String REVOKED = "REVOKED"; private static final String READ = "READ"; private static final String NULL_FIELD = "-"; private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); - private final ConcurrentMap issuedLinks = new ConcurrentHashMap<>(); private final ConcurrentLinkedQueue readEvents = new ConcurrentLinkedQueue<>(); private final Path ledgerPath; - /** - * Creates an in-memory artifact link ledger. - */ + /** Creates an in-memory artifact link ledger. */ public ArtifactLinkLedger() { this((Path) null); } /** * Creates an artifact link ledger with optional file-backed persistence. - * * @param ledgerPath configured append-only ledger path */ @Autowired @@ -62,12 +57,6 @@ public ArtifactLinkLedger(@Value("${clearfolio.artifact-link-ledger.path:}") Str /** * Records an issued artifact link. - * - *

Token identifiers are immutable authorization identities. Reusing an - * already-issued identifier is rejected before durable append or process-local - * publication so a collision or caller error cannot rebind an existing token - * to another tenant, subject, or document.

- * * @param record issued artifact link record * @throws IllegalStateException when the token identifier was already issued or durable append fails */ @@ -81,7 +70,6 @@ public synchronized void recordIssued(ArtifactLinkRecord record) { /** * Finds an issued artifact link by token identifier. - * * @param tokenId token identifier * @return matching record when present */ @@ -94,7 +82,6 @@ public Optional findByTokenId(String tokenId) { /** * Marks an issued artifact link as revoked. - * * @param tokenId token identifier * @param revokedAt revocation timestamp * @param revokedBy subject requesting revocation @@ -102,10 +89,7 @@ public Optional findByTokenId(String tokenId) { * @return updated record when the token exists */ public synchronized Optional revoke( - String tokenId, - Instant revokedAt, - String revokedBy, - String reason) { + String tokenId, Instant revokedAt, String revokedBy, String reason) { ArtifactLinkRecord current = issuedLinks.get(tokenId); if (current == null || current.isRevoked()) { return Optional.ofNullable(current); @@ -118,7 +102,6 @@ public synchronized Optional revoke( /** * Records a verified artifact read. - * * @param event artifact read event */ public synchronized void recordRead(ArtifactReadEvent event) { @@ -128,7 +111,6 @@ public synchronized void recordRead(ArtifactReadEvent event) { /** * Returns read events for a tenant-owned document. - * * @param tenantId tenant identifier * @param docId document identifier * @return current matching read events @@ -147,7 +129,7 @@ private void load() { try (Stream lines = Files.lines(ledgerPath, StandardCharsets.UTF_8)) { lines.forEach(this::replayLine); } catch (java.nio.file.NoSuchFileException ex) { - // Ignore missing ledger file + // Ignore missing ledger file. } catch (IOException | UncheckedIOException ex) { throw new IllegalStateException("artifact link ledger cannot be loaded", ex); } @@ -168,21 +150,13 @@ private void replayIssued(String[] fields) { throw invalidLine(); } ArtifactLinkRecord record = new ArtifactLinkRecord( - requiredValue(fields[1]), - requiredValue(fields[2]), - requiredValue(fields[3]), - uuid(fields[4]), - requiredValue(fields[5]), - requiredValue(fields[6]), - requiredValue(fields[7]), - value(fields[8]), - instant(fields[9]), - instant(fields[10]), - instant(fields[11]), - value(fields[12]), - value(fields[13]) - ); - issuedLinks.put(record.tokenId(), record); + requiredValue(fields[1]), requiredValue(fields[2]), requiredValue(fields[3]), + uuid(fields[4]), requiredValue(fields[5]), requiredValue(fields[6]), + requiredValue(fields[7]), value(fields[8]), instant(fields[9]), + instant(fields[10]), instant(fields[11]), value(fields[12]), value(fields[13])); + if (issuedLinks.putIfAbsent(record.tokenId(), record) != null) { + throw invalidLine(); + } } private void replayRevoked(String[] fields) { @@ -194,11 +168,7 @@ private void replayRevoked(String[] fields) { if (current == null) { throw invalidLine(); } - issuedLinks.put(tokenId, current.revoked( - instant(fields[2]), - value(fields[3]), - value(fields[4]) - )); + issuedLinks.put(tokenId, current.revoked(instant(fields[2]), value(fields[3]), value(fields[4]))); } private void replayRead(String[] fields) { @@ -206,15 +176,9 @@ private void replayRead(String[] fields) { throw invalidLine(); } readEvents.add(new ArtifactReadEvent( - requiredValue(fields[1]), - requiredValue(fields[2]), - uuid(fields[3]), - requiredValue(fields[4]), - value(fields[5]), - statusCode(fields[6]), - value(fields[7]), - instant(fields[8]) - )); + requiredValue(fields[1]), requiredValue(fields[2]), uuid(fields[3]), + requiredValue(fields[4]), value(fields[5]), statusCode(fields[6]), + value(fields[7]), instant(fields[8]))); } private void appendLine(String line) { @@ -223,66 +187,34 @@ private void appendLine(String line) { } try { Files.createDirectories(ledgerPath.toAbsolutePath().getParent()); - Files.writeString( - ledgerPath, - line + System.lineSeparator(), - StandardCharsets.UTF_8, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.APPEND - ); + Files.writeString(ledgerPath, line + System.lineSeparator(), StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND); } catch (IOException ex) { throw new IllegalStateException("artifact link ledger cannot be written", ex); } } private static String serializeIssued(ArtifactLinkRecord record) { - return String.join("\t", - ISSUED, - field(record.tokenId()), - field(record.tenantId()), - field(record.subjectId()), - record.docId().toString(), - field(record.scope()), - field(record.purpose()), - field(record.artifactChecksum()), - field(record.viewerSessionId()), - field(record.issuedAt()), - field(record.expiresAt()), - field(record.revokedAt()), - field(record.revokedBy()), - field(record.revokeReason()) - ); + return String.join("\t", ISSUED, field(record.tokenId()), field(record.tenantId()), + field(record.subjectId()), record.docId().toString(), field(record.scope()), + field(record.purpose()), field(record.artifactChecksum()), field(record.viewerSessionId()), + field(record.issuedAt()), field(record.expiresAt()), field(record.revokedAt()), + field(record.revokedBy()), field(record.revokeReason())); } private static String serializeRevoked(ArtifactLinkRecord record) { - return String.join("\t", - REVOKED, - field(record.tokenId()), - field(record.revokedAt()), - field(record.revokedBy()), - field(record.revokeReason()) - ); + return String.join("\t", REVOKED, field(record.tokenId()), field(record.revokedAt()), + field(record.revokedBy()), field(record.revokeReason())); } private static String serializeRead(ArtifactReadEvent event) { - return String.join("\t", - READ, - field(event.tenantId()), - field(event.subjectId()), - event.docId().toString(), - field(event.tokenId()), - field(event.rangeRequested()), - String.valueOf(event.statusCode()), - field(event.traceId()), - field(event.readAt()) - ); + return String.join("\t", READ, field(event.tenantId()), field(event.subjectId()), + event.docId().toString(), field(event.tokenId()), field(event.rangeRequested()), + String.valueOf(event.statusCode()), field(event.traceId()), field(event.readAt())); } private static String field(String value) { - return value == null - ? NULL_FIELD - : ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8)); + return value == null ? NULL_FIELD : ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8)); } private static String field(Instant instant) { From 316cf5fe8c6a7233ccce6abb079a943861a30f2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:12:15 +0900 Subject: [PATCH 08/10] refactor(reliability): keep ledger replay fix minimal --- .../viewer/artifact/ArtifactLinkLedger.java | 118 ++++++++++++++---- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java index 55e6297f..69c88643 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java @@ -26,23 +26,28 @@ */ @Repository public class ArtifactLinkLedger { + private static final String ISSUED = "ISSUED"; private static final String REVOKED = "REVOKED"; private static final String READ = "READ"; private static final String NULL_FIELD = "-"; private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + private final ConcurrentMap issuedLinks = new ConcurrentHashMap<>(); private final ConcurrentLinkedQueue readEvents = new ConcurrentLinkedQueue<>(); private final Path ledgerPath; - /** Creates an in-memory artifact link ledger. */ + /** + * Creates an in-memory artifact link ledger. + */ public ArtifactLinkLedger() { this((Path) null); } /** * Creates an artifact link ledger with optional file-backed persistence. + * * @param ledgerPath configured append-only ledger path */ @Autowired @@ -57,6 +62,12 @@ public ArtifactLinkLedger(@Value("${clearfolio.artifact-link-ledger.path:}") Str /** * Records an issued artifact link. + * + *

Token identifiers are immutable authorization identities. Reusing an + * already-issued identifier is rejected before durable append or process-local + * publication so a collision or caller error cannot rebind an existing token + * to another tenant, subject, or document.

+ * * @param record issued artifact link record * @throws IllegalStateException when the token identifier was already issued or durable append fails */ @@ -70,6 +81,7 @@ public synchronized void recordIssued(ArtifactLinkRecord record) { /** * Finds an issued artifact link by token identifier. + * * @param tokenId token identifier * @return matching record when present */ @@ -82,6 +94,7 @@ public Optional findByTokenId(String tokenId) { /** * Marks an issued artifact link as revoked. + * * @param tokenId token identifier * @param revokedAt revocation timestamp * @param revokedBy subject requesting revocation @@ -89,7 +102,10 @@ public Optional findByTokenId(String tokenId) { * @return updated record when the token exists */ public synchronized Optional revoke( - String tokenId, Instant revokedAt, String revokedBy, String reason) { + String tokenId, + Instant revokedAt, + String revokedBy, + String reason) { ArtifactLinkRecord current = issuedLinks.get(tokenId); if (current == null || current.isRevoked()) { return Optional.ofNullable(current); @@ -102,6 +118,7 @@ public synchronized Optional revoke( /** * Records a verified artifact read. + * * @param event artifact read event */ public synchronized void recordRead(ArtifactReadEvent event) { @@ -111,6 +128,7 @@ public synchronized void recordRead(ArtifactReadEvent event) { /** * Returns read events for a tenant-owned document. + * * @param tenantId tenant identifier * @param docId document identifier * @return current matching read events @@ -129,7 +147,7 @@ private void load() { try (Stream lines = Files.lines(ledgerPath, StandardCharsets.UTF_8)) { lines.forEach(this::replayLine); } catch (java.nio.file.NoSuchFileException ex) { - // Ignore missing ledger file. + // Ignore missing ledger file } catch (IOException | UncheckedIOException ex) { throw new IllegalStateException("artifact link ledger cannot be loaded", ex); } @@ -150,10 +168,20 @@ private void replayIssued(String[] fields) { throw invalidLine(); } ArtifactLinkRecord record = new ArtifactLinkRecord( - requiredValue(fields[1]), requiredValue(fields[2]), requiredValue(fields[3]), - uuid(fields[4]), requiredValue(fields[5]), requiredValue(fields[6]), - requiredValue(fields[7]), value(fields[8]), instant(fields[9]), - instant(fields[10]), instant(fields[11]), value(fields[12]), value(fields[13])); + requiredValue(fields[1]), + requiredValue(fields[2]), + requiredValue(fields[3]), + uuid(fields[4]), + requiredValue(fields[5]), + requiredValue(fields[6]), + requiredValue(fields[7]), + value(fields[8]), + instant(fields[9]), + instant(fields[10]), + instant(fields[11]), + value(fields[12]), + value(fields[13]) + ); if (issuedLinks.putIfAbsent(record.tokenId(), record) != null) { throw invalidLine(); } @@ -168,7 +196,11 @@ private void replayRevoked(String[] fields) { if (current == null) { throw invalidLine(); } - issuedLinks.put(tokenId, current.revoked(instant(fields[2]), value(fields[3]), value(fields[4]))); + issuedLinks.put(tokenId, current.revoked( + instant(fields[2]), + value(fields[3]), + value(fields[4]) + )); } private void replayRead(String[] fields) { @@ -176,9 +208,15 @@ private void replayRead(String[] fields) { throw invalidLine(); } readEvents.add(new ArtifactReadEvent( - requiredValue(fields[1]), requiredValue(fields[2]), uuid(fields[3]), - requiredValue(fields[4]), value(fields[5]), statusCode(fields[6]), - value(fields[7]), instant(fields[8]))); + requiredValue(fields[1]), + requiredValue(fields[2]), + uuid(fields[3]), + requiredValue(fields[4]), + value(fields[5]), + statusCode(fields[6]), + value(fields[7]), + instant(fields[8]) + )); } private void appendLine(String line) { @@ -187,34 +225,66 @@ private void appendLine(String line) { } try { Files.createDirectories(ledgerPath.toAbsolutePath().getParent()); - Files.writeString(ledgerPath, line + System.lineSeparator(), StandardCharsets.UTF_8, - StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND); + Files.writeString( + ledgerPath, + line + System.lineSeparator(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND + ); } catch (IOException ex) { throw new IllegalStateException("artifact link ledger cannot be written", ex); } } private static String serializeIssued(ArtifactLinkRecord record) { - return String.join("\t", ISSUED, field(record.tokenId()), field(record.tenantId()), - field(record.subjectId()), record.docId().toString(), field(record.scope()), - field(record.purpose()), field(record.artifactChecksum()), field(record.viewerSessionId()), - field(record.issuedAt()), field(record.expiresAt()), field(record.revokedAt()), - field(record.revokedBy()), field(record.revokeReason())); + return String.join("\t", + ISSUED, + field(record.tokenId()), + field(record.tenantId()), + field(record.subjectId()), + record.docId().toString(), + field(record.scope()), + field(record.purpose()), + field(record.artifactChecksum()), + field(record.viewerSessionId()), + field(record.issuedAt()), + field(record.expiresAt()), + field(record.revokedAt()), + field(record.revokedBy()), + field(record.revokeReason()) + ); } private static String serializeRevoked(ArtifactLinkRecord record) { - return String.join("\t", REVOKED, field(record.tokenId()), field(record.revokedAt()), - field(record.revokedBy()), field(record.revokeReason())); + return String.join("\t", + REVOKED, + field(record.tokenId()), + field(record.revokedAt()), + field(record.revokedBy()), + field(record.revokeReason()) + ); } private static String serializeRead(ArtifactReadEvent event) { - return String.join("\t", READ, field(event.tenantId()), field(event.subjectId()), - event.docId().toString(), field(event.tokenId()), field(event.rangeRequested()), - String.valueOf(event.statusCode()), field(event.traceId()), field(event.readAt())); + return String.join("\t", + READ, + field(event.tenantId()), + field(event.subjectId()), + event.docId().toString(), + field(event.tokenId()), + field(event.rangeRequested()), + String.valueOf(event.statusCode()), + field(event.traceId()), + field(event.readAt()) + ); } private static String field(String value) { - return value == null ? NULL_FIELD : ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8)); + return value == null + ? NULL_FIELD + : ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8)); } private static String field(Instant instant) { From 58fa7c414c3e0236fa3d546a9f921b08b9f8be1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:18:53 +0900 Subject: [PATCH 09/10] test(reliability): require durable append before authority publication --- .../ArtifactLinkLedgerDurabilityTest.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerDurabilityTest.java diff --git a/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerDurabilityTest.java b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerDurabilityTest.java new file mode 100644 index 00000000..204839e4 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerDurabilityTest.java @@ -0,0 +1,64 @@ +package com.clearfolio.viewer.artifact; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ArtifactLinkLedgerDurabilityTest { + + @Test + void doesNotPublishIssuedAuthorityWhenDurableAcknowledgementFails(@TempDir Path tempDir) { + Path ledgerPath = tempDir.resolve("artifact-links.ledger"); + ArtifactLinkLedger ledger = new ArtifactLinkLedger(ledgerPath, (path, line) -> { + Files.writeString( + path, + line, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND + ); + throw new IOException("durable acknowledgement failed"); + }); + ArtifactLinkRecord record = issuedRecord(); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> ledger.recordIssued(record) + ); + + assertEquals("artifact link ledger cannot be written", failure.getMessage()); + assertTrue(ledger.findByTokenId(record.tokenId()).isEmpty()); + assertTrue(Files.exists(ledgerPath)); + } + + private static ArtifactLinkRecord issuedRecord() { + Instant issuedAt = Instant.parse("2026-08-11T04:00:00Z"); + return new ArtifactLinkRecord( + "token-1", + "tenant-1", + "subject-1", + UUID.fromString("00000000-0000-0000-0000-000000000357"), + "artifact:read", + "viewer-preview", + "checksum", + null, + issuedAt, + issuedAt.plusSeconds(300), + null, + null, + null + ); + } +} From 548f29f207618f13f7980b9500ddf9f9ecdbcafe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:21:55 +0900 Subject: [PATCH 10/10] fix(reliability): force durable ledger append before publication --- .../viewer/artifact/ArtifactLinkLedger.java | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java index 69c88643..b867377c 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java @@ -2,6 +2,8 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -10,6 +12,7 @@ import java.time.Instant; import java.util.Base64; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -37,6 +40,7 @@ public class ArtifactLinkLedger { private final ConcurrentMap issuedLinks = new ConcurrentHashMap<>(); private final ConcurrentLinkedQueue readEvents = new ConcurrentLinkedQueue<>(); private final Path ledgerPath; + private final DurableLineAppender durableLineAppender; /** * Creates an in-memory artifact link ledger. @@ -56,7 +60,12 @@ public ArtifactLinkLedger(@Value("${clearfolio.artifact-link-ledger.path:}") Str } ArtifactLinkLedger(Path ledgerPath) { + this(ledgerPath, ArtifactLinkLedger::appendDurably); + } + + ArtifactLinkLedger(Path ledgerPath, DurableLineAppender durableLineAppender) { this.ledgerPath = ledgerPath; + this.durableLineAppender = Objects.requireNonNull(durableLineAppender, "durableLineAppender"); load(); } @@ -224,20 +233,27 @@ private void appendLine(String line) { return; } try { - Files.createDirectories(ledgerPath.toAbsolutePath().getParent()); - Files.writeString( - ledgerPath, - line + System.lineSeparator(), - StandardCharsets.UTF_8, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.APPEND - ); + durableLineAppender.append(ledgerPath, line + System.lineSeparator()); } catch (IOException ex) { throw new IllegalStateException("artifact link ledger cannot be written", ex); } } + private static void appendDurably(Path ledgerPath, String line) throws IOException { + Files.createDirectories(ledgerPath.toAbsolutePath().getParent()); + try (FileChannel channel = FileChannel.open( + ledgerPath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND)) { + ByteBuffer bytes = StandardCharsets.UTF_8.encode(line); + while (bytes.hasRemaining()) { + channel.write(bytes); + } + channel.force(true); + } + } + private static String serializeIssued(ArtifactLinkRecord record) { return String.join("\t", ISSUED, @@ -349,4 +365,9 @@ private static IllegalStateException invalidLine() { private static IllegalStateException invalidLine(Throwable cause) { return new IllegalStateException("artifact link ledger contains an invalid line", cause); } + + @FunctionalInterface + interface DurableLineAppender { + void append(Path ledgerPath, String line) throws IOException; + } }