diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java index 897b95b2..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,18 +60,32 @@ 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(); } /** * 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) { - issuedLinks.put(record.tokenId(), record); + if (issuedLinks.containsKey(record.tokenId())) { + throw new IllegalStateException("artifact token identifier is already issued"); + } appendLine(serializeIssued(record)); + issuedLinks.put(record.tokenId(), record); } /** @@ -97,18 +115,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 +131,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); } /** @@ -177,7 +191,9 @@ private void replayIssued(String[] fields) { value(fields[12]), value(fields[13]) ); - issuedLinks.put(record.tokenId(), record); + if (issuedLinks.putIfAbsent(record.tokenId(), record) != null) { + throw invalidLine(); + } } private void replayRevoked(String[] fields) { @@ -217,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, @@ -342,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; + } } 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..15239ae8 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/ArtifactLinkLedgerAuthorityRebindingTest.java @@ -0,0 +1,105 @@ +package com.clearfolio.viewer.artifact; + +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; +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()); + } + + @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, + tenantId, + "subject-a", + docId, + ArtifactLinkService.ARTIFACT_READ_SCOPE, + "viewer-preview", + "checksum", + null, + Instant.EPOCH, + Instant.EPOCH.plusSeconds(300), + null, + null, + 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)); + } +} 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 + ); + } +} 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); + } +} 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)) ); }